2023.11.05最終更新日:
2023.11.05
今回は添付した単一画像を、React Hook Formというライブラリを使ってデータ送信するまでの実装について解説していきます。
文字列等を送信する場合と異なり、添付画像を送信する場合の実装は少し厄介な点や工夫すべき点がありますので、参考までに読んでいっていただけますと幸いです。
また、本記事はこちらの記事の応用的な内容になっています。単にReactで画像を添付する方法のみを知りたい方はそちらをご参照ください。
【React/TypeScript】単一画像のアップロード+プレビュー機能を実装するエンジニアとして日頃の気づきやアウトプットを投稿しているブログです。koutaroinoue-log.com
前提
- React/Next.jsを使った実装を行ったことがある。
- React Hook Formを使用したことがある。
- 事前準備として、
react-hook-form
をインストールしておいてください。
- 事前準備として、
環境
- OS: MacOS
- Next v13.4.19
- React v18.2.0
- React Hook Form v7.47.0
それでは早速手順の解説に移ります。
1. 画像を添付するInputを作成する
まずは、画像を添付するための<input/>
要素を作成していきましょう。
下記の例では、可読性的にコンポーネント化していますが、直接<input/>
要素を用意していただいても問題ありません。
tsx// ImageInput/index.tsx import React, { InputHTMLAttributes } from 'react'; export type Props = { onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; id: InputHTMLAttributes<HTMLInputElement>['id']; } & React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement >; export const ImageInput: React.FC<Props> = ({ onChange, id, ...rest, }) => { return ( <input id={id} type="file" accept="image/*" onChange={onChange} hidden {...rest} /> ); };
<input type=”file”/>
とするとデフォルトでは以下のような見た目になります。
今回は、上記の見た目を変更し、任意のUIにしたいため hidden
で隠しておきます。
2. 画像アップロード用のフィールド・フォームを作成する
続いて、1. で作成したinput要素を使って、画像アップロード用のフィールドを作成していきます。(※便宜上、スタイルを追加しています。)
ここでのポイントをまとめます。
- 作成したフィールドを
<form>
タグで囲む。- これによって、フォームによるデータ送信を実装できます。
- また、フォーム送信用のsubmitボタンを用意しています。
tsx// SendImageForm/index.tsx 'use client'; import { ImageInput } from '@/components/ImageInput'; import { inputField, container, buttonGroup, } from './style.css'; const IMAGE_ID = 'image'; export const SendImageForm: React.FC = () => { return ( <form className={container}> <div className={inputField}> 画像をアップロード {/* ダミーインプット: 見えない */} <ImageInput onChange={() => {}} id={IMAGE_ID} /> </div> {/* キャンセルボタン */} <div className={buttonGroup}> <button>× キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
ここまでのUIになります。
現時点では、ボタンを押したり、フィールドをクリックしても何も機能しません。
3. 画像を添付できるようにする
まずは、<imageInput/>
コンポーネントを修正していきます。
tsx// ImageInput/index.tsx import React, { InputHTMLAttributes } from 'react'; import { RefCallBack } from 'react-hook-form'; export type Props = { onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; id: InputHTMLAttributes<HTMLInputElement>['id']; fileInputRef: React.MutableRefObject<HTMLInputElement | null>; } & React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement >; export const ImageInput: React.FC<Props> = ({ onChange, id, fileInputRef, ...rest }) => { return ( <input ref={fileInputRef} // refを受け取れるようにする。 id={id} type="file" accept="image/*" onChange={onChange} hidden {...rest} /> ); };
変更内容としては以下の通りです。
fileInputRef
でrefを受け取れるようにする。
その上で、フィールドをクリックして画像を選択し、添付できるようにしていきます。
先程のフォームの実装を修正します。
tsx// SendImageForm/index.tsx 'use client'; import { ImageInput } from '@/components/ImageInput'; import { useRef, useState } from 'react'; import { inputField, container, buttonGroup, } from './style.css'; const IMAGE_ID = 'image'; export const SendImageForm: React.FC = () => { const fileInputRef = useRef<HTMLInputElement>(null); // 添付画像を状態管理 const [imageFile, setImageFile] = useState<File | null>(null); const selectFile = () => { if (!fileInputRef.current) return; // ローカルフォルダーから画像選択のダイアログが表示される。 fileInputRef.current.click(); }; const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.currentTarget?.files && e.currentTarget.files[0]) { const targetFile = e.currentTarget.files[0]; setImageFile(targetFile); } }; console.log(imageFile); // 添付画像のデータをコンソールに出力する。 return ( <form className={container}> <div className={inputField} onClick={selectFile} role="presentation"> 画像をアップロード {/* ダミーインプット: 見えない */} <ImageInput fileInputRef={fileInputRef} onChange={handleFileChange} id={IMAGE_ID} /> </div> {/* キャンセルボタン */} <div className={buttonGroup}> <button>× キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
変更内容としては
fileInputRef.current.click()
で画像をローカルフォルダーから選択するためのダイアログを開くようにする。handleFileChange
関数を作成し、イベントリスナーonChange
に入れることで、inputに画像が添付された際に、値を変更するようにする。- その値をStateで管理しています。
それでは、実際に画像を添付してみましょう。
実際に画像が添付されていることがわかるかと思います。
4. 添付画像のプレビュー・キャンセル機能を実装する
続いて、以下の実装を行っていきます。
- 上記で添付された画像を画面上に表示させる(プレビュー)
- 添付画像をUI上から消し、inputからも削除する。(キャンセル)
プレビュー
まずは、添付した画像データを元に、画像をUI上にプレビューする機能を追加していきます。
tsx'use client'; import { ImageInput } from '@/components/ImageInput'; import { useRef, useState } from 'react'; import { inputField, image, container, buttonGroup } from './style.css'; const IMAGE_ID = 'image'; export const SendImageForm: React.FC = () => { const fileInputRef = useRef<HTMLInputElement>(null); const [imageFile, setImageFile] = useState<File | null>(null); const [fileName, setFileName] = useState(''); const [imageSource, setImageSource] = useState(''); const selectFile = () => { if (!fileInputRef.current) return; fileInputRef.current.click(); }; // ファイルが読み込まれた際に、画像データを抽出する処理 const generateImageSource = (files: FileList) => { const file = files[0]; const fileReader = new FileReader(); setFileName(file.name); fileReader.onload = () => { setImageSource(fileReader.result as string); }; fileReader.readAsDataURL(file); }; const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (!files || files.length <= 0) return; generateImageSource(files); // img要素のsrc属性に渡す画像データを生成 setImageFile(files[0]); }; console.log(imageFile); return ( <form className={container}> <div className={inputField} onClick={selectFile} role="presentation"> {/* 画像があればプレビューし、なければ「+ 画像をアップロード」を表示 */} {fileName ? ( <img src={imageSource} alt="アップロード画像" className={image} /> ) : ( '+ 画像をアップロード' )} {/* ダミーインプット: 見えない */} <ImageInput fileInputRef={fileInputRef} onChange={handleFileChange} id={IMAGE_ID} /> </div> {/* キャンセルボタン */} <div className={buttonGroup}> <button>× キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
上記では、添付されたファイルが読み込まれた際に、画像のデータを抽出するための関数generateImageSource
を作成しています。( FileReader
オブジェクトを使用。 )
添付画像をプレビューできるようになりました!
キャンセル
加えて、選択した画像をキャンセルする機能を追加していきましょう。
キャンセル用の関数 handleClickCancelButton
を作成していきます。
tsx'use client'; import { ImageInput } from '@/components/ImageInput'; import { useRef, useState } from 'react'; import { inputField, image, container, buttonGroup } from './style.css'; const IMAGE_ID = 'image'; export const SendImageForm: React.FC = () => { const fileInputRef = useRef<HTMLInputElement>(null); const [imageFile, setImageFile] = useState<File | null>(null); const [fileName, setFileName] = useState(''); const [imageSource, setImageSource] = useState(''); const selectFile = () => { if (!fileInputRef.current) return; fileInputRef.current.click(); }; const generateImageData = (files: FileList) => { const file = files[0]; const fileReader = new FileReader(); setFileName(file.name); fileReader.onload = () => { setImageSource(fileReader.result as string); }; fileReader.readAsDataURL(file); }; const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (!files || files.length <= 0) return; generateImageData(files); setImageFile(files[0]); }; // キャンセルボタンを押した際の処理 const handleClickCancelButton = () => { setFileName(''); setImageSource(''); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; console.log(imageFile); return ( <form className={container}> <div className={inputField} onClick={selectFile} role="presentation"> {/* 画像があればプレビューし、なければ「+ 画像をアップロード」を表示 */} {fileName ? ( <img src={imageSource} alt="アップロード画像" className={image} /> ) : ( '+ 画像をアップロード' )} {/* ダミーインプット: 見えない */} <ImageInput fileInputRef={fileInputRef} onChange={handleFileChange} id={IMAGE_ID} /> </div> {/* キャンセルボタン */} <div className={buttonGroup}> <button onClick={handleClickCancelButton}>× キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
State(fileName
, imageSource
)のみリセットするだけでは、inputに画像データが残ったままになってしまうので、fileInputRef.current.value = ''
でvalueを空の文字列に更新してあげましょう。
キャンセルボタンを押してうまく動作していれば問題ありません。
5. アップロード画像を送信する
ここまでで、画像を添付するための実装は終わりです。
ここからは、上記で添付した画像をReact Hook Formを使ってデータ送信するまでの実装を行なっていきましょう。
主な手順としては以下の通りです。
useForm
をインポートして、<ImageInput/>
に必要なプロパティを渡す。- フォーム送信の処理(
onSubmit
)を追加して、フォームのデータを送信する。
1. useFormをインポートして、データ送信に必要な設定を行う
それでは、React Hook FormからuseForm
というHooksをインポートして、フォームを管理していきます。
以下、実装後のコードになります。
( SendImageForm/index.ts
が長くなってきましたので、ロジック部分をローカルHooksに切り出しています。)
SendImageForm/useSendImageForm.ts
tsximport { useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; export const IMAGE_ID = 'image'; type FormValues = { image: FileList; }; export const useSendImageForm = () => { // React Hook FormのuseFormから、各値・関数を使用する。 const { register, handleSubmit, formState: { errors }, } = useForm<FormValues>(); const fileInputRef = useRef<HTMLInputElement | null>(null); const [imageFile, setImageFile] = useState<File | null>(null); const [imageSource, setImageSource] = useState(''); const [fileName, setFileName] = useState(''); const selectFile = () => { if (!fileInputRef.current) return; fileInputRef.current.click(); }; const generateImageData = (files: FileList) => { const file = files[0]; const fileReader = new FileReader(); setFileName(file.name); fileReader.onload = () => { setImageSource(fileReader.result as string); }; fileReader.readAsDataURL(file); }; const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (!files || files.length <= 0) return; generateImageData(files); setImageFile(files[0]); }; const handleClickCancelButton = () => { setImageFile(null); // <input />タグの値をリセット if (fileInputRef.current) { fileInputRef.current.value = ''; } }; // ↓追加↓ // フォーム送信に必要な値・関数の登録 const { ref, ...rest } = register(IMAGE_ID, { onChange: handleFileChange, // フォームの値を変更する required: 'ファイルを選択してください', // フォームを必須項目にする }); return { fileName, imageSource, fileInputRef, errors: errors.image, rest, ref, onSubmit, handleSubmit, handleClickCancelButton, selectFile, }; };
SendImageForm/index.tsx
tsx'use client'; import { ImageInput } from '@/components/ImageInput'; import { inputField, image, container, buttonGroup, errorMessage, } from './style.css'; import { useSendImageForm, IMAGE_ID } from './useSendImageForm'; export const SendImageForm: React.FC = () => { const { fileName, imageSource, fileInputRef, errors, rest, ref, handleSubmit, handleClickCancelButton, selectFile, } = useSendImageForm(); return ( <form className={container} onSubmit={handleSubmit(() => {})}> <div className={inputField} onClick={selectFile} role="presentation"> {fileName ? ( <img src={imageSource} alt="アップロード画像" className={image} /> ) : ( '+ 画像をアップロード' )} {/* ダミーインプット: 見えない */} <ImageInput fileInputRef={fileInputRef} refCallback={ref} id={IMAGE_ID} {...rest} /> </div> <span className={errorMessage}>{errors?.message?.toString()}</span> {/* キャンセルボタン */} <div className={buttonGroup}> <button onClick={handleClickCancelButton}>×キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
<ImageInput/>
コンポーネントも若干修正します。
ImageInput/index.tsx
tsximport React, { InputHTMLAttributes } from 'react'; import { RefCallBack } from 'react-hook-form'; export type Props = { onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; id: InputHTMLAttributes<HTMLInputElement>['id']; refCallback: RefCallBack; fileInputRef: React.MutableRefObject<HTMLInputElement | null>; } & React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement >; export const ImageInput: React.FC<Props> = ({ onChange, id, fileInputRef, refCallback, ...rest }) => { return ( <input ref={(e) => { refCallback(e); fileInputRef.current = e; }} id={id} type="file" accept="image/*" onChange={onChange} hidden {...rest} /> ); };
上記の追加内容・ポイントを解説します。
特に注目していただきたい実装が以下の箇所になります。
tsxconst { ref, ...rest } = register(IMAGE_ID, { onChange: handleFileChange, required: 'ファイルを選択してください', });
少し特殊なことをしているように感じられるかと思います。
今回はReact Hook Formでフォームの制御を行っていますが、そのためにはinput要素(今回の場合は<ImageInput/>
)を取得し、その内容を収集する必要があります。(inputに入力された値をフォーム送信するため。)
そのinput要素の「取得・内容の収集」に ref
が使われます。
ただ、何も考えずにref属性をinputに指定してしまうと、react-hook-formのuseFormで作られるrefと、独自で作成したrefが干渉してしまうので、
ただ、以下のように安直に register
を指定しまうと、React Hook Form(のregister
)で作られるrefと、自分で作成したref (fileInputRef
)が干渉してしまい、値が入りません。
tsx// ref同士が干渉してしまう。 <ImageInput {...register('image')} // react-hook-formのrefが含まれる。 fileInputRef={fileInputRef} // 独自で作成したref id={IMAGE_ID} />
従って、refの指定方法を少し考える必要があります。
以下のようにすることで、React Hook Formのrefと独自のrefが干渉し合うことなく、別々に指定することが可能です。
tsx// refを受け取るinput要素 type Props = { refCallback: RefCallBack; fileInputRef: React.MutableRefObject<HTMLInputElement | null>; .. }; <input ref={(e) => { refCallback(e); fileInputRef.current = e; }} ... />
これらに関してはReact Hook Form公式のFAQにも記載がありましたので、そちらも併せてご参照ください。
Performant, flexible and extensible forms with easy-to-use validation.react-hook-form.com
2. フォーム送信時の処理を追加する
最後に、実際にフォームを送信した際の処理を実装していきましょう。
フォームの送信処理にはReact Hook Formの handleSubmit
という関数を使用します。
中身の処理に関しては、独自で作成した onSubmit
という関数を渡します。
tsx// SendImageForm/useSendImageForm.ts import { useGetImageUrl } from '@/hooks/useGetImageUrl'; import { useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; export const IMAGE_ID = 'image'; type FormValues = { image: FileList; }; export const useSendImageForm = () => { // (...上記省略) // フォーム送信時の処理 const onSubmit = async (data: FormValues) => { // データ送信する処理を記述する。 console.log('送信されたデータ:', data); }; return { imageFile, imageUrl, fileInputRef, errors: errors.image, rest, ref, onSubmit, handleSubmit, handleClickCancelButton, selectFile, }; };
tsx// SendImageForm/index.tsx 'use client'; import { ImageInput } from '@/components/ImageInput'; import { inputField, image, container, buttonGroup, errorMessage, } from './style.css'; import { useSendImageForm, IMAGE_ID } from './useHomePage'; export const SendImageForm: React.FC = () => { const { imageFile, imageUrl, fileInputRef, errors, rest, ref, onSubmit, // 追加 handleSubmit, handleClickCancelButton, selectFile, } = useSendImageForm(); // handleSubmitに送信時の処理を渡す。 return ( <form className={container} onSubmit={handleSubmit(onSubmit)}> <div className={inputField} onClick={selectFile} role="presentation"> {fileName ? ( <img src={imageSource} alt="アップロード画像" className={image} /> ) : ( '+ 画像をアップロード' )} {/* ダミーインプット: 見えない */} <ImageInput fileInputRef={fileInputRef} refCallback={ref} id={IMAGE_ID} {...rest} /> </div> <span className={errorMessage}>{errors?.message?.toString()}</span> {/* キャンセルボタン */} <div className={buttonGroup}> <button onClick={handleClickCancelButton}>×キャンセル</button> <button type="submit">画像を送信</button> </div> </form> ); };
実際に画像を添付し、「画像を送信」ボタンを押下すると、以下のように画像データが送信されていることが確認できるかと思います。
画像が添付されていない状態で送信しようとするとエラーメッセージが表示されます。
また、onSubmit
の中は非同期処理になっていますので、APIと疎通する処理を入れることが可能です。
以上で、React Hook Formを使って添付した画像データをフォーム送信するまでの説明は終了です。
お疲れ様でした!
おわりに
今回の内容はrefのカラクリを知っていないと躓きがちなポイントかなと思います。
これから実務等で似たような実装を行う方の参考になれれば幸いです。
また、今回使用したサンプルコードを公開リポジトリの方にアップロードしておきますので、是非ご活用ください。
GitHub - koutaro0205/submit-single-file-with-rhfContribute to koutaro0205/submit-single-file-with-rhf development by creating an account on GitHub.github.com
最後まで読んでいただきありがとうございます。
See you !!
参考文献
Reactで画像入力フォームをカスタマイズする(react-hook-form対応)|CTC BuildサービスチームWebサイトでファイルの入力を受け取りたいとき、input要素のtype属性に"file"を指定することで実現できます。しかし、これだけではデフォルトのスタイルが適用されてしまい、サイト全体のイメージnote.com
inputタグのtype='file'のvalue属性は変更できないzenn.dev
Performant, flexible and extensible forms with easy-to-use validation.react-hook-form.com