React Hook Formで非同期の値を扱う場合はこれに統一しませんか?
TL;DR
結論:React Hook Form 特有の予測しづらい機能を使うのをやめよう
// ✅ データ取得とフォーム用のコンポーネントに分離
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>
)
}
// ❌ 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 セレクトボックスのリストとして利用する(ただしこのリストは動的に変化する)フォーム」 を実装する機会がありました。
(以下はあくまでイメージです。実際の実装とは異なります。)
要件
-
入力項目は「ユーザー名」「アカウント名」「メールアドレス」「プロフィール画像」「アルバム」「写真」
-
「ユーザー名」 「アルバム」 「写真」 はそれぞれセレクトボックスからの選択になっており、クエリパラメータなどから取得した
userId
albumId
photoId
を初期値として設定する- 今回は簡易的に定数ファイルを用意し初期値として設定
-
各セレクトボックスのリストは以下の要件に従って設定される
- ユーザー名・アルバム・写真のリストは「ユーザー名 → アルバム」「アルバム → 写真」のように影響し合う
- ユーザー名リスト
- 固定=動的に変化しない
- アルバムリスト
- 「選択されたユーザー名に紐づいたアルバムリスト」 をセット= 「ユーザー名」に応じて動的に変化する
- 写真リスト
- 「選択されたアルバムに紐づいた写真リスト」 をセット= 「アルバム」に応じて動的に変化する
- 各リストは API から取得したデータを用いる
- リスト初期値も API から取得したデータを用いる
- 1 の
userId
とalbumId
を利用して API を叩く
- 1 の
-
アカウント名・メールアドレスは、1 の
userId
を利用して API から取得したユーザーデータを用いて初期値として設定 -
プロフィール画像は、1 の
photoId
を利用して API から取得した写真データを用いて初期値として設定 -
あとは通常通りのフォームとして機能する
完成形
※フォームの実装には shadcn/ui
[1]を用いています。
見た目と動作イメージは以下の通り。
当初の実装方針
当初はフォームの実装に React Hook Form の v6 系を用いていたこともあり、reset
を用いて初期値を更新するという方針を採用していました。
今回のアプリケーションでreset
を使用した場合は、以下のようなコードになります。
フォーム用コンポーネント
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"が初期値で設定されてほしい)
これは 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.
事実、多くの人がこの特殊な仕様に困っているようでv7 系でvalues
と呼ばれる「非同期的に初期値をセットする API 」が追加されたときには沢山の人が喜んでいたようです。(スタンプを見ればなんとなく分かる。)
さらに、reset
にはuseEffect
の依存配列の問題もあります。
管理する項目が増えてきたら面倒ですし、また親からの再レンダリングで意図せず値が新しく計算されてしまう...というリスクもあるため、できれば避けたいです。
さらにさらにuseEffect
を 「A の値が更新されたのを検知して更新する」 のように変更検知のために用いるのは React 的にもあまりよろしくありません。
問題点 ③:バックグラウンドでのデータ更新に対応していない
最後に忘れがちなのが、バックグラウンドでデータ更新が起こった場合への対処です。
reset
のみの場合ユーザーがフォーム上で初期値を書き換えていたとしても、(バックグラウンドでデータ更新が起こると)フォーム全体が更新された値に置き換えられてしまいます。
つまり、ユーザーが入力中だった値は無視されてしまうのです。
これを防ぐには、以下のサンプルコードのようにreset
のオプションにkeepDirtyValues
を指定すれば良いようです。
こうすることで、 「書き換えられた値は残したまま初期値をリセットしたい」 という複雑な要件にも対応できます。
結局どうすれば良いのか??
さてreset
を用いた場合上記のような問題点があるわけですが、どう対処していけばよいでしょうか?
まず考えられるのが先程から言及しているような React Hook Form の機能 を最大限活用することです。
具体的にはvalues
やkeepDirtyValues
といったオプションを利用します。
ただこれは結局 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 のリセット」 も考えられます。
上記ドキュメントにもあるように、key
を変えて「コンポーネントを破棄 → 再マウント」させることでdefaultValues
がキャッシュされてしまうのを防ぎます。
今回のアプリケーションに適用させると以下の感じでしょうか。
(key
の生成には 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 特有の機能に依存しないフォームの実装法について自分なりの考えを整理してみました。
複雑なフォームになればなるほど、こちらの実装の方が個人的には見通しが良くなると感じていますので、もしフォームの実装法について困ってる方がいらっしゃたらぜひ参考にしてみてください!
最後までご覧いただき、ありがとうございました。
参考文献
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion
とてもいい記事でした。
確かに react hook form 的には
と Promise<FieldValues> で取得する方法がありますが、useQueryとの組み合わせは相性が悪く、かといってこの記事で言及されているように useEffect で無理やりやるのはメンテナンス性が下がる。
データを取得してから下層コンポーネントを描画するのは useEffect を避ける際によく使いますが、useFromの初期化に応用することは思いつきませんでした。
大変勉強になりました。