📝

React Hook FormのuseFieldArrayを使って複数画像管理を行うライブラリ作成の試みと諦観

に公開

本当はこの記事のタイトルは React Hook Formの useFieldArray を使って複数画像管理を行うライブラリ作成を試みて、どうにか動くものは出来たけど独善的かつ黒魔術的で手に負えない形になったので供養します。叩き台にしてください。 としたかったのですがZennのタイトルの字数制限がそれを許してくれなかったんです。
先にお詫びしておきます。

リポジトリ(結論)

https://github.com/curry-battle/rhf-zod-form-with-multiple-images

動作イメージ

対象読者

  • React Hook FormのuseFieldArrayで苦しんだ人
    • useFieldArrayを使わずに自前のstateで配列管理したほうがいいのではと悩んだ人
  • React Hook Formで使うinputやらを抽象化しようとして悩んだ人
  • useFieldArrayを使い複数画像を管理する実装を頑張って実現したもののハチャメチャになった人

前提

複数画像管理という言葉について

今回の記事の言う複数画像管理とは、
フォームの中で複数の画像(登録後に増減ができる)を登録できるアレを指します。
input type="file" に対して一度に複数枚の画像を選択して登録するアレではないです。

後述するリポジトリでの実装がまさにそうなっていますが、「ユーザのプロフィール編集画面で、複数枚のプロフィール画像を管理」みたいなユースケースを想定した実装をしています。

今回実現しようとしたこと、実装の前提

今回は以下の操作を実現しました

  • 既存画像 (つまりサーバから取得した画像) の更新
  • 既存画像の削除
  • 新規画像の追加
  • 新規/既存含めた並び替え

実装の前提としては、フロントエンドからバックエンドにFileを渡さずに、フロントエンドから直接S3 PresignedURLでアップロードを行う形にしています。

バックエンドのAPIには以下が生えている想定で作っています。多少変わってもどうにかなるはず。

  • S3 PresignedURLを取得 (取得するだけ。DBを更新しない)
  • DBを更新 (S3にアップロードされたURLを含めたメタデータを永続化)

概要

React Hook Formの useFieldArray を使ってぐちゃぐちゃな実装をした経験が人生に何度かあります。似たような人もきっといるんじゃないでしょうか。

例えば:

  • 画像のプレビューとして表示する対象には「S3由来のファイルのURL」, 「アップロード前にローカルで生成した URL.createObjecturl() のデータ (blob:~)」とパターンがあってややこしい
  • データをSubmitする時にややこしい処理をしてしまい泥沼にハマる
    • 例えば「サーバから取得した既存の画像が削除対象かどうか判定するためにinitalValueとFormの現在の状態を比較して……」
  • 処理とUIが密結合しすぎて、どうしようもない

今回はForm内で取り回す画像を取り回す際に、Discriminated UnionやらState Machineやらを活用することで、複雑な処理の苦しみが逃れられるような実装を模索しました。
ついでにControllerパターンに落とし込み、ややこしい内部処理とUIの描画を分離しました。臭いものに蓋をした気持ちです。

Controllerパターンでこういう感じに使えるイメージです。

タラタラと長い能書きでしたが実装はGithubに載せています。(再掲)

https://github.com/curry-battle/rhf-zod-form-with-multiple-images

実装について

リポジトリに執拗なコメントを残しているんですが、そこに書いたり書いてなかったりすることをここで補足しておきます。
ここを見たからといって全容が掴めるわけでもないのでやや心苦しいです。メモ程度の話かも。

Controller パターン

上述のこれです。

ついでにControllerパターンに落とし込み、ややこしい内部処理とUIの描画を分離しました。

React Hook FormのControllerに似たような感じで使えるようにしています。

こういう感じ

<MultiImageInputController
  // React Hook Formのcontrol
  control={control}
  name="profileImages"
  // その他のprops
  render={({
    itemsWithErrors,
    handleAdd,
    handleFileChange,
    handleDelete,
    handleMoveUp,
    handleMoveDown
  }) => {
    return (
      // itemsWithErrors, handleAdd, ... を利用して描画しつつ、描画したコンポーネントにhandlerをもたせたり
    );
  }}
/>

State Machine

State Machine的な感じに画像の状態を管理しています。
実装見たほうが早いかも。

https://github.com/curry-battle/rhf-zod-form-with-multiple-images/blob/main/src/libs/MultiImageController/types/ImageStatus.ts

  • new:画像追加ボタンを押してファイル選択済みの状態。DB/S3には未登録
  • existing: DB/S3に登録済みの状態
  • deleted: DB/S3に登録済みの画像が削除指定された状態
状態遷移:
 (file選択)--> new --(DB登録)--> existing --(削除)--> deleted
               └----(削除)--> removed (管理対象外)

画面上には new / existing の画像のみが表示される形にしています。
existingから画像の差し替えを行う場合は一度existingをdeletedにし、新たにnewを作成する運び。

以下のファイルではそのStateを組み込んだオブジェクトに対する操作をまとめています。

https://github.com/curry-battle/rhf-zod-form-with-multiple-images/blob/main/src/libs/MultiImageController/types/Image.ts

transition周りの処理ではnarrowingを上手く効かせたくて、結果として魔術的な実装が多いです。
// 型の意味がわからないが動くとコメントが差してあるような箇所については祈りと共にAIに吐かせたコードです。

React Hook Formの型調整

React Hook Formでフォームを作る時、label, input, errorあたりをひとまとめにした汎用的なコンポーネントを作りたくなったりすると思います。例えばこういうの。

<Label>{label}</Label>
<input
  // いろんなprops
/>
<Description>{description}</Description>
{erorr && <p errors={[fieldState.error]}</<p>

こうして作ったコンポーネントはユーザの名前だったり、メールアドレスだったり、いろんなとこに使い回すことになるわけで、きっとこのコンポーネントのPropsはこういう感じになると思います。

interface MyInputTextProps<T extends FieldValues> {
  control: Control<T>;
  name: Path<T>;
  error?: FieldError;
  // その他いろんなprops
}

// FieldValues, Control, Path, FieldErrorはすべてReact Hook Formからimportしている型!

これはシンプルな例なんですが、これがuseFieldArrayになった途端にかなりややこしくなった経験はないですか?僕だけ?
Path? ArrayPath? わからん! 僕は型安全にしたいだけなのに!anyやらunknownやら使いたくないし!
こんな思いをするならもはや汎用的なコンポーネントにするのを諦めたほうがいいんじゃないか!?
と悩みながら今回の実装をしました。結局unknownで逃げてますけどね……。

という苦しみの型調整の跡がこの辺に刻まれています。
https://github.com/curry-battle/rhf-zod-form-with-multiple-images/blob/main/src/libs/MultiImageController/hooks/useMultiImageInputController.tsx

※ ユーザプロフィールのスキーマ/フォームでしか使えない実装にしてしまえばもっと楽に作ることができる。

おわりに

これを叩き台にしてもっといいライブラリを誰かが作ってくれることを切に祈ります。
叩くときは優しく叩いてください♡

いつかTanStack Formで書き直してみたいです。

Discussion