恐らく、ここ最近のReact.js/Next.jsで作られているもののフォームはほぼほぼReact Hook Formが使われているだろう。zodと組み合わせて堅牢なフォームが作れるのは確かだ。
ただ、使い方は人によってかなり違ってくると思う。なので個人的な備忘録を残しておきます!
【参考サイト】


非同期データを初期値に使う場合どうする?
❌setValue
setValueは初期値を入れる用途では使用しない
🔺resetを使う
defaultValuesより優先される
const { reset } = useForm()
const { data } = useSWR();
useEffect(() => {
reset(data)
}, [data])
useEffectでreset対象を複数watchする必要があり、一度だけresetしたい場合にrefなどで制御しないといけない
⭕️ valuesを使う
v7からvaluesが登場。defaultValuesより優先される。
const { data: values } = useSWR();
useForm({
values, // valuesは一度しか更新されない
})
useMemoですべてのデータが揃った時だけ値を返し、それ以外の場合はundefinedを返せば意図したデータになる。
defaultValuesと併用する場合にどの最終的に値がどうなるか想定しずらいがcreateValuesなどの関数に初期値生成のロジックをまとめることで解決する。
⭕️ defaultValuesを使う
v7からdefaultValuesにPromiseを渡すことができる。
useForm({
defaultValues: async () => await fetchData()
})
useSWRではpromiseを解決してからdataを返すため相性が悪い。
以下のようにuseSWRMutationを使うとdefaultValuesでpromiseを解決できる。
const { trigger } = useFetchUserByMutation();
const {
formState: { isLoading },
handleSubmit,
register,
reset,
} = useForm({
defaultValues: async () => {
const result = await trigger();
return { ...result };
},
});
// useSWR
export const useFetchUserByMutation = () => {
return useSWRMutation(API_PATH, fetcher);
};
hooksをstate用とhandler用に分けるべきか
メリット
- 関心の分離
- ファイルあたりのコード量が減る
デメリット
- hooks間でisDirtyなどのstateを引き回す必要がある
あまり分けるメリットがなさそう。
コード量が減ることに関して
そもそもコードが多くなる原因はreset部分とsubmit時のparam部分。
createDefaultValuesやcreateRequestParamなどのutilsに切り出す方がより関心も分離し見やすくなる。
tips
mode: ‘onChange’を使わない
入力内容が変化するたびにエラーメッセージに反映され、ユーザー体験が悪い。
isSubmittingを使う
submitボタンのdisabledにisSubmittingを渡し、複数回form内容がリクエストされないようにする。
handleSubmitに渡した関数のpromiseが完了するまでtrueになる。
const { handleSubmit, formState: { isSubmitting } } = useForm();
resolverを使う
useForm({
resolver: zodResolver(schema),
});
resetOptions
useForm({
values,
resetOptions: {
keepDirtyValues: true, // reset時にユーザーが入力した内容が保持される
},
})
これらを踏まえたFormの実装方法
schemaとFormで管理するデータの型を作成
// schema.ts
export const schema = z.object({
name: z.string(),
age: z.coerce.number()
})
export type FormInput = z.infer<typeof schema>
fetchしたデータからdefaultValuesの生成、formデータからrequestデータの変換は関数化する
formで管理していないデータをrequestに含める場合はformInputToXXXのpayloadに渡す。
// utils.ts
export const createDefaultValues = (
payload?: User
): FormInput => {
return {
name: payload?.name ?? '',
age: payload?.age ?? '',
}
}
export const formInputToUser = (
data: FormInput
): User => {
return {
name: data.name,
age: data.age,
}
}
初期値の取得・formデータの管理・submit後の処理実装を行うhooks
// use-user-form.ts
type Props = {
userId: number
}
export const useUserForm = ({ userId }: Props) => {
const { notif } = useNotification()
const { data, error, loading } = useUser(userId)
const values = useMemo(
() => (data != null ? createDefaultValues(data) : undefined),
[data]
)
const form = useForm<FormInput>({
resolver: zodResolver(schema),
values,
defaultValues: createDefaultValues()
})
const onSubmit: SubmitHandler<FormInput> = useCallback(
async (data) => {
try {
await updateUser({
userId,
user: formInputToUser(data)
})
notif({ variant: 'success', title: 'ユーザーを更新しました' })
} catch {
notif({ variant: 'error', title: 'ユーザーの更新に失敗しました。' })
}
},
[userId, notif]
)
return {
error,
loading,
onSubmit,
...form
}
}
ロジックは基本的にuseXXXFormに丸投げし、controlを引き回したUIを実装
// user-form.tsx
type Props = {
userId: number
}
export const UserForm: FC<Props> = ({ userId }) => {
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
onSubmit
} = useUserForm({ userId })
return (
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Box display="grid" gap={4}>
<TextField control={control} name="name" />
<TextField control={control} name="age" />
</Box>
<Button type="submit">保存</Button>
</Box>
)
}
入力コンポーネントはcontrolを引数に取る<Controller>でwrapしたものを用意しておく
export const TextField = <T extends FieldValues>({
control,
name,
label,
...props
}: Props<T>) => {
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
return (
<FormControl fullWidth={true}>
<_TextField
label={label}
{...props}
{...omit(field, ['disabled'])}
error={fieldState.error != null}
/>
{fieldState.error != null && (
<FormHelperText error={true}>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl>
)
}}
/>
)
}
個人的な所感ですが、とてもキレイにまとまってると思います。もちろんケースバイケースになるのでその都度考える必要はありますが。
バージョンアップのたびにリファクタが必要になるので大変ですね。