🙋‍♂️

React Hook Formで非同期の値を扱う場合はこれに統一しませんか?

2023/10/29に公開1

TL;DR

結論:React Hook Form 特有の予測しづらい機能を使うのをやめよう

✅OK例
// ✅ データ取得とフォーム用のコンポーネントに分離
function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })

  // データ読み込みが完了したらレンダー
  if (data) {
    return <PersonForm person={data} />
  }

  // 読み込みが未完了ならフォールバックコンテンツを表示
  return 'loading...'
}

function PersonForm({ person }) {
  const { register, handleSubmit } = useForm({ defaultValues: person })
  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <div>
        <label htmlFor="firstName">First Name</label>
        <input {...register('firstName')} />
      </div>
      <div>
        <label htmlFor="lastName">Last Name</label>
        <input {...register('lastName')} />
      </div>
      <input type="submit" />
    </form>
  )
}
❌NG例
// ❌ resetを使用するパターン
function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { register, handleSubmit, reset } = useForm({
    defaultValues: {
      firstName: "",
      lastName: "",
    },
  })

  useEffect(() => {
    reset(data)
  }, [reset, data])

  return (
    ...
  )
}

// ❌ valuesを使用するパターン
function PersonDetail({ id }) {
  const { data } = useQuery({
    queryKey: ['person', id],
    queryFn: () => fetchPerson(id),
  })
  const { register, handleSubmit } = useForm({
    values: data,
    resetOptions: {
      keepDirtyValues: true, // ユーザーが入力中の値は保持したまま、defaultValuesを更新する。
    },
  })

  return (
    ...
  )
}

事の経緯

いきなり結論からぶっ込んだわけですが、このままだと背景などがわからないと思いますので、順を追って説明していきます。

実務で以下のような 「非同期的に取得した値を初期値として利用する or セレクトボックスのリストとして利用する(ただしこのリストは動的に変化する)フォーム」 を実装する機会がありました。
(以下はあくまでイメージです。実際の実装とは異なります。)

要件

  1. 入力項目は「ユーザー名」「アカウント名」「メールアドレス」「プロフィール画像」「アルバム」「写真」

  2. 「ユーザー名」 「アルバム」 「写真」 はそれぞれセレクトボックスからの選択になっており、クエリパラメータなどから取得した userId albumId photoId を初期値として設定する

    • 今回は簡易的に定数ファイルを用意し初期値として設定
  3. 各セレクトボックスのリストは以下の要件に従って設定される

    • ユーザー名・アルバム・写真のリストは「ユーザー名 → アルバム」「アルバム → 写真」のように影響し合う
    • ユーザー名リスト
      • 固定=動的に変化しない
    • アルバムリスト
      • 「選択されたユーザー名に紐づいたアルバムリスト」 をセット= 「ユーザー名」に応じて動的に変化する
    • 写真リスト
      • 「選択されたアルバムに紐づいた写真リスト」 をセット= 「アルバム」に応じて動的に変化する
    • 各リストは API から取得したデータを用いる
    • リスト初期値も API から取得したデータを用いる
      • 1 のuserIdalbumId を利用して API を叩く
  4. アカウント名・メールアドレスは、1 のuserIdを利用して API から取得したユーザーデータを用いて初期値として設定

  5. プロフィール画像は、1 のphotoIdを利用して API から取得した写真データを用いて初期値として設定

  6. あとは通常通りのフォームとして機能する

完成形

https://github.com/youliangdao/async-values-form

※フォームの実装には shadcn/ui[1]を用いています。

見た目と動作イメージは以下の通り。

当初の実装方針

当初はフォームの実装に React Hook Form の v6 系を用いていたこともあり、resetを用いて初期値を更新するという方針を採用していました。

https://react-hook-form.com/docs/useform/reset

今回のアプリケーションでresetを使用した場合は、以下のようなコードになります。

フォーム用コンポーネント
components/forms/Form.tsx
export function SelectForm() {

  // アカウント名・メールアドレス・プロフィール画像の初期値を設定するために必要な値をAPI側から取得する
  const { data: user } = useGetUsersUserId(Number(INITIAL_USER_ID));
  const { data: photo } = useGetPhotosPhotoId(Number(INITIAL_PHOTO_ID));

  const form = useForm<FormSchemaType>({
    resolver: zodResolver(FormSchema),
    // 一旦仮の初期値をセットしておく
    defaultValues: {
      userId: INITIAL_USER_ID,
      albumId: INITIAL_ALBUM_ID,
      photoId: INITIAL_PHOTO_ID,
      accountName: "",
      thumbnail: "",
      email: "",
    },
  });

  const userId = form.watch("userId");
  const albumId = form.watch("albumId");

  // 各セレクトボックスのリストを取得するカスタムフック
  const { userOptions, albumOptions, photoOptions } = useOptions({
    userId: String(userId),
    albumId: String(albumId),
  });

  function onSubmit(data: FormSchemaType) {
    ...
  }

  // API側のデータが取得できたら値をセットし直す
  useEffect(() => {
    form.reset({
      userId: INITIAL_USER_ID,
      albumId: INITIAL_ALBUM_ID,
      photoId: INITIAL_PHOTO_ID,
      accountName: user?.data.name,
      thumbnail: photo?.data.thumbnailUrl,
      email: user?.data.email,
    });
  }, [user, photo, form]);

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
        <div className="border p-6 space-y-3">
          {/** ユーザー名入力用のセレクボックス */}
          <FormField
            control={form.control}
            name="userId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>ユーザー名</FormLabel>
                <Select
                  onValueChange={(value) => {
                    field.onChange(value);
                    form.setValue("albumId", 0);
                    form.setValue("photoId", 0);
                  }}
                  value={String(field.value)}
                >
                  ...
                  <SelectContent>
                    {userOptions.map((option) => (
                      <SelectItem key={option.value} value={option.value}>
                        {option.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
          {/** アカウント名入力用のテキスト入力フォーム */}
          <FormField
            control={form.control}
            name="accountName"
            render={({ field }) => (
              <FormItem>
                <FormLabel>アカウント名</FormLabel>
                <FormControl>
                  <Input placeholder="shadcn" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          {/** メールアドレス入力用のテキスト入力フォーム */}
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メールアドレス</FormLabel>
                <FormControl>
                  <Input placeholder="Email" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          {/** プロフィール画像用の入力フォーム */}
          <FormField
            control={form.control}
            name="thumbnail"
            render={({field: { onChange, value, ...rest }}) => (
              <FormItem>
                <FormLabel>プロフィール画像</FormLabel>
                <FormControl>
                  <Input
                    type="file"
                    {...rest}
                    onChange={...}
                  />
                </FormControl>
              </FormItem>
            )}
          />
          <Avatar className="w-24 h-24">
            <AvatarImage src={preview} />
            <AvatarFallback>BU</AvatarFallback>
          </Avatar>
        </div>
        {/** アルバム名入力用のセレクボックス */}
        <FormField
          control={form.control}
          name="albumId"
          render={({ field }) => (
            <FormItem>
              <FormLabel>アルバム</FormLabel>
              <Select
                onValueChange={(value) => {
                  field.onChange(value);
                  form.setValue("photoId", 0);
                }}
                value={String(field.value)}
              >
                ...
                <SelectContent>
                  {albumOptions.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        {/** 写真入力用のセレクボックス */}
        <FormField
          control={form.control}
          name="photoId"
          render={({ field }) => (
            <FormItem>
              <FormLabel>写真</FormLabel>
              <Select
                onValueChange={field.onChange}
                value={String(field.value)}
              >
                ...
                <SelectContent>
                  {photoOptions.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

重要なのは以下の部分です。

// アカウント名・メールアドレス・プロフィール画像の初期値を設定するために必要な値をAPI側から取得する
const { data: user } = useGetUsersUserId(Number(INITIAL_USER_ID));
const { data: photo } = useGetPhotosPhotoId(Number(INITIAL_PHOTO_ID));

...

// API側のデータが取得できたら値をセットし直す
useEffect(() => {
  form.reset({
    userId: INITIAL_USER_ID,
    albumId: INITIAL_ALBUM_ID,
    photoId: INITIAL_PHOTO_ID,
    accountName: user?.data.name,
    thumbnail: photo?.data.thumbnailUrl,
    email: user?.data.email,
  });
}, [user, photo, form]);

よく見かけるコードですね。

ただこの実装でフォームを運用していくに当たって、いくつか問題点が発生しました。

問題点 ①:セレクトボックスのリストより先に初期値をセットすると正しく反映されない

地味に困ったのがこれです。

以下のようにセレクトボックスを非制御コンポーネントとして扱うとリストを非同期的に取得してセットした場合、初期値が正しくセットされません。
(本当だったら"correct option"が初期値で設定されてほしい)

https://github.com/orgs/react-hook-form/discussions/7750

これは UX 的に見てもよくありません。
例えばフォームの入力項目が多いサービスなどではセレクトボックスに予め値を入れておくというのは十分考えられるため、できれば避けたいです。

問題点 ②:defaultValuesでセットした値を再度resetするというのが直感的ではない

次に問題点として挙げられるのが、このresetの処理自体が直感的ではないという点です。

React Hook Form の挙動的には最初のレンダリング時点でdefaultValuesの値をキャッシュしてしまうため、API から取得してきたデータを初期値に反映させたい場合はresetで一度値をリセットしなければなりません。

しかし、この考え方は特殊で直感に反します。(直感的にはdefaultValuesの値が変わったら勝手に初期値が変わってほしくない?)

defaultValues are cached on the first render within the custom hook. If you want to reset the defaultValues, you should use the reset api.

https://legacy.react-hook-form.com/v6/api/

事実、多くの人がこの特殊な仕様に困っているようでv7 系でvaluesと呼ばれる「非同期的に初期値をセットする API 」が追加されたときには沢山の人が喜んでいたようです。(スタンプを見ればなんとなく分かる。)

https://github.com/react-hook-form/react-hook-form/releases/tag/v7.40.0-next.0

https://react-hook-form.com/faqs#Howtoinitializeformvalues

さらに、resetにはuseEffectの依存配列の問題もあります。

管理する項目が増えてきたら面倒ですし、また親からの再レンダリングで意図せず値が新しく計算されてしまう...というリスクもあるため、できれば避けたいです。

さらにさらにuseEffect「A の値が更新されたのを検知して更新する」 のように変更検知のために用いるのは React 的にもあまりよろしくありません。

https://ja.react.dev/learn/you-might-not-need-an-effect

問題点 ③:バックグラウンドでのデータ更新に対応していない

最後に忘れがちなのが、バックグラウンドでデータ更新が起こった場合への対処です。

resetのみの場合ユーザーがフォーム上で初期値を書き換えていたとしても、(バックグラウンドでデータ更新が起こると)フォーム全体が更新された値に置き換えられてしまいます

つまり、ユーザーが入力中だった値は無視されてしまうのです。

これを防ぐには、以下のサンプルコードのようにresetのオプションにkeepDirtyValuesを指定すれば良いようです。
こうすることで、 「書き換えられた値は残したまま初期値をリセットしたい」 という複雑な要件にも対応できます。

結局どうすれば良いのか??

さてresetを用いた場合上記のような問題点があるわけですが、どう対処していけばよいでしょうか?

まず考えられるのが先程から言及しているような React Hook Form の機能 を最大限活用することです。

具体的にはvalueskeepDirtyValuesといったオプションを利用します。

ただこれは結局 React Hook Form の予測しづらい機能に依存してしまっており、(resetを使う場合と)可読性という面であまり変わらない気がします。

結局のところ、非同期系の処理に関してはReact Hook Form からの依存をやめないことにはいつまで経ってもわかりやすいコードをかけないのです。

修正を加えていく 🚀

ということで思い切って、React Hook Form にあまり依存しないようなフォームに修正していきましょう。

今回はもし編集中にバックグラウンドでデータ更新が走ってしまい、その変更内容が state に反映された場合でも無視する方向でいきます。(問題点 ③ については無視)

最もシンプルな方法は、 「データ取得用のコンポーネント」と「描画用のコンポーネント」 の2つに分けることです。

今回のアプリケーションに適用すると以下のような感じ。

データ取得用とフォーム描画用に分ける
// データ取得のための親コンポーネント
export const IndexPage = () => {
  // 要件4と5に対応するためのデータ取得
  const { data: user } = useGetUsersUserId(Number(INITIAL_USER_ID));
  const { data: photo } = useGetPhotosPhotoId(Number(INITIAL_PHOTO_ID));

  // React Hook FormのdefaultValuesを定義
  const defaultValues: FormSchemaType = {
    // ここのINITIAL_◯◯◯は要件2に対応
    userId: INITIAL_USER_ID,
    albumId: INITIAL_ALBUM_ID,
    photoId: INITIAL_PHOTO_ID,
    accountName: "",
    thumbnail: "",
    email: "",
  };

  // user と photoの読み込みが完了したらレンダー
  if (user && photo) {
    return <SelectForm {...{ defaultValues, user, photo }} />;
  }
  // 読み込み未完了ならフォールバック
  return <p>Loading...</p>;
};

// フォームを描画するためのコンポーネント
export function SelectForm({
  defaultValues: initialValues,
  user,
  photo,
}: {...}) {
  // defaultValuesなどを用いてフォームを定義
  const form = useForm<FormSchemaType>({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      ...initialValues,
      accountName: user.data.name,
      email: user.data.email,
      thumbnail: photo.data.thumbnailUrl,
    },
  });

  const userId = form.watch("userId");
  const albumId = form.watch("albumId");

  // セレクトされたユーザーとアルバムに応じて、各セレクトボックスのリストを取得するカスタムフック
  // 要件3に対応
  const { userOptions, albumOptions, photoOptions } = useOptions({
    userId: String(userId),
    albumId: String(albumId),
  });

  function onSubmit(data: FormSchemaType) {
    // 送信処理
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        ...
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

API から取得したデータの存在が保証されているため、そのままdefaultValuesに設定すればよく、非常にわかりやすいです。

さらに、もうひとつの方法としては key Prop を用いた state のリセット」 も考えられます。

https://ja.react.dev/learn/preserving-and-resetting-state#resetting-a-form-with-a-key

https://zenn.dev/yumemi_inc/articles/react-initial-state-take-advantage#2-c.-key-を使ってリセットする

上記ドキュメントにもあるように、key を変えて「コンポーネントを破棄 → 再マウント」させることでdefaultValuesがキャッシュされてしまうのを防ぎます。

今回のアプリケーションに適用させると以下の感じでしょうか。
keyの生成には object-hash と呼ばれるライブラリを利用しています。)

https://github.com/puleos/object-hash

export const IndexPage = () => {
  ...
  // React Hook FormのdefaultValuesを定義
  const defaultValues: FormSchemaType = {
    // ここのINITIAL_◯◯◯は要件2に対応
    userId: INITIAL_USER_ID,
    albumId: INITIAL_ALBUM_ID,
    photoId: INITIAL_PHOTO_ID,
    // ここは要件4.5に対応
    accountName: user?.data.name ?? "",
    thumbnail: photo?.data.thumbnailUrl ?? "",
    email: user?.data.email ?? "",
  };

  return (
    <SelectForm
      // key を変化させることでフォームコンポーネントを無理やり「破棄→再マウント」させる
      key={objectHash(defaultValues)}
      {...{ defaultValues }}
    />;
  )
};

若干玄人感がありますが、こちらは条件分岐なども必要なく、かつデータ読み込みが未完了でもフォールバックコンテンツを表示する必要がなくなるため、よりユーザーに配慮した実装と言えるでしょう。

まとめ

ということで、React Hook Form 特有の機能に依存しないフォームの実装法について自分なりの考えを整理してみました。

複雑なフォームになればなるほど、こちらの実装の方が個人的には見通しが良くなると感じていますので、もしフォームの実装法について困ってる方がいらっしゃたらぜひ参考にしてみてください!

最後までご覧いただき、ありがとうございました。

参考文献

https://zenn.dev/yumemi_inc/articles/react-initial-state-take-advantage#はじめに%3A-止まらないデータの流れを意図的に止めることが可能

https://ja.react.dev/learn/preserving-and-resetting-state#resetting-a-form-with-a-key

https://react-hook-form.com/faqs#Howtoinitializeformvalues

https://tkdodo.eu/blog/react-query-and-forms

https://qiita.com/taro28/items/df91fc26b3ba461d9d5e

https://github.com/orgs/react-hook-form/discussions/7750

脚注
  1. https://ui.shadcn.com/docs/components/form ↩︎

COUNTERWORKS テックブログ

Discussion

みんてぃみんてぃ

とてもいい記事でした。
確かに react hook form 的には

// set default value async
useForm({
  defaultValues: async () => fetch('/api-endpoint');
})

と Promise<FieldValues> で取得する方法がありますが、useQueryとの組み合わせは相性が悪く、かといってこの記事で言及されているように useEffect で無理やりやるのはメンテナンス性が下がる。

データを取得してから下層コンポーネントを描画するのは useEffect を避ける際によく使いますが、useFromの初期化に応用することは思いつきませんでした。
大変勉強になりました。