2023.05.15

FileInputの汎用コンポーネントを作りました

ファイルアップロード機能はたまに必要になりますが、久しぶりに実装すると思ってもないところでちょっと詰まったり沼ったりします。<br> そこでReactで使いまわせる汎用的なFileInputコンポーネントを作りました。<br> 今回もMantineのDropzoneを使い、React Hook FormとZodでバリデーションします。MantineとReact Hook FormとZodでフォームを作るの続編です。

作ったもの

コードは前回と同じリポジトリにpushしてあります。

mantine-fileinput-empty

アップロードするにはdropzoneエリアをクリックしてファイルを選択するか、ドラッグ&ドロップでアップロードすることができます。ファイルは複数アップロードすることができます。<br> ロジックはuseFileInputというhookに切り出しました。今回はFirebase StorageにアップロードするものBase64にエンコードしてObjectURLを取得するものの2種類を作りました。

使用技術

詳細は前回書いているので割愛します。<br> 前回からのアップデートとしてはMantineのメジャーバージョンがv6に上がりました。破壊的な変更がいくつか入り、各プロジェクトでは影響を受けつつも、ライブラリが常に更新されている安心感もあります。<br> 例えばModalやSpotlightなどのコンポーネントでoverlayやtransitionのpropsがすっきり書けるようになりました。<br> ニッチなコンポーネントにも定評があるMantineですが、v6からPinInputのコンポーネントが追加されました。今のところ使う場面はなさそうですが見た目がかわいくてすきです。

実装

具体的な実装について解説します。<br> UIはFileInputというコンポーネントにまとめ、ロジックはuseFileInput hookに切り出しました。UIとロジックについてそれぞれ見ていきます

UIについて

Dropzone自体はほぼMantineのものを使用しており、テキストやアイコンだけ自分で設定したものになっています。<br> アップロードされた画像はプレビューが表示されます。 mantine-fileinput-preview2images loading時やdisable時はoverlayがかかります。<br> 受け付けるファイル数やファイル数、拡張子のバリデーションもコンポーネントに書くことができます。

<Dropzone
  // 他のpropsは省略
  maxSize={100 * 1024 ** 2}
  accept={IMAGE_MIME_TYPE}
  multiple
  maxFiles={max - files.length}
  disabled={states.disabled}
>

FileInputコンポーネントで受け取った値をReact Hook Formには直接registerできないため、Controllerを使います。RHFのuseForm hookで渡ってくるcontrolを使うことで任意のフィールドの値を参照したり更新したりすることができます。

<Controller
  name="files"
  control={control}
  render={({ field }) => (
    <FileInput
      defaultValue={field.value}
      setFiles={field.onChange}
      maxFiles={4}
      error={errors.files?.message}
    />
  )}
/>

ロジックについて

ファイルアップロードのロジックはすべてuseFileInputというhookに切り出しました。<br> hookは引数として既存のファイル一覧、ファイルをmutateする関数、ファイルの上限数の3つを受け取ります。そして返り値としてはファイルの一覧、ハンドラ、ステートを返します。ハンドラにはファイル追加とファイル削除の関数が含まれており、ステートにはdisableとloadingがあります。この辺りの実装はMantineのuse-disclosure hookを参考にしました。

export const useFileInput = (
  files: Array<FileObject>,
  setFiles: (files: Array<FileObject>) => void,
  maxFiles: number,
): [
  Array<FileObject>,
  {
    add: (files: Array<File>) => void
    remove: (index: number) => void
  },
  {
    disabled: boolean
    loading: boolean
  },
] => {

コンポーネント側でhookを呼ぶと下のようになり、handlersに必要な処理がまとまっているためすっきり書けます。

// hookを呼ぶとき
const [files, handlers, states] = useFileInput(
  defaultValue,
  setFiles,
  maxFiles,
)

// コンポーネントで使うとき
<Dropzone
  onDrop={handlers.add}
  // 他のpropsは省略
/>

hookのメインはonChangeメソッドです。コンポーネント側でファイルが選択されるこちらが発火します。ファイルは複数選択できるのでinputFilesの数だけループします。<br> Base64に変換するhookではFileReaderでエンコードしつつ、URL.createObjectURL(file)でプレビュー用のURLを取得してファイル一覧の配列に追加しています。

const newFiles: Array<FileObject> = []
for (let i = 0; i < inputFiles.length; i++) {
  const file = inputFiles[i]
  const reader = new FileReader()
  reader.readAsDataURL(file)
  await new Promise<void>(
    (resolve) =>
      (reader.onloadend = () => {
        const { result } = reader as { result: string }
        if (result.startsWith('data:image/')) {
          newFiles.push({
            file_base64: result,
            file_name: file.name,
            object_url: URL.createObjectURL(file),
          })
        }
        resolve()
      }),
  )
}
setFiles([...files, ...newFiles])

Storageにアップロードする方では実際の処理はuploadImage内で行っており、アップロードしたファイルのURLが返ってくるのでそちらをファイル一覧の配列に追加しています。

const newFiles: Array<FileObject> = []
for (let i = 0; i < inputFiles.length; i++) {
  const file = files[i]
  const filename = uuid()
  const fileURL = await uploadImage(`${storagePath}/${filename}`, file)
  newFiles.concat(fileURL)
}
setFiles([...files, ...newFiles])

さいごに

FileReaderのonloadendの扱いがむずかしくて詰まりました。<br> RHFのControllerが便利なので他にも汎用コンポーネントを作りたいと思いました。