React Hook FormをやめてuseReducerを使用した話
はじめに
HERP Careersの開発をしている松山です。
今回は応募フォームの実装で、React Hook FormからuseReducer
に移行した話をしてみたいと思います。
フォームライブラリの選定って結構悩みどころだと思うのですが、今回の事例が参考になれば嬉しいです。
フォームライブラリの技術選定
検討対象として考えていたのは以下の2つでした。
- React Hook Form
- useReducer(Reactの標準機能)
Formikという選択肢もあったのですが、React Hook Formと使い方が似ていることと、困った時に記事が多い方が助かるので今回はFormikは選ばずにライブラリを使うならReact Hook Form、そうでないならuseReducerを検討対象にしました。
技術選定の背景と理由
プロジェクトの概要
実装するフォームの概要はこんな感じでした。
- 採用の応募フォーム(コアとなる重要な機能)
- 入力項目が多く、バリデーションも複雑
- 途中離脱を防ぐことが将来的に重要なKPI(になりそう)
- toC向けのWebサイト
チームの状況
開発チームの構成はこんな感じです。
- エンジニア3名の小規模チーム
- フルスタック開発が基本
- 2/3がReact Hook Form未経験
導入するかどうかの観点
React Hook Formは2019年に始まったOSSで継続年数的には枯れたOSSに入る部類かなと思います。
個人的に受託を受けていた時にはハマりどころもあったもののスター数もあるし、shadcnでも使用されていてデファクトスタンダードになりつつあるライブラリだと思っていました。
あとは、学習コストと実際にやってみて今回の機能要件と非機能要件(主にメンテナンス性)にマッチするかを確かめようという状態でした。
早速ディレクトリ構成や実装について紹介します。
React Hook Formでの実装
ディレクトリ構造はこのような感じにしました。これは後述するuseReducerでもほぼ変わりません。
.
├── components
│ ├── ApplyForm.tsx
│ ├── ApplyFormContext.tsx
│ ├── BirthDayFields.tsx
│ ├── apply-form-schema.ts
│ └── parts
│ ├── FormErrorMessage.tsx
│ └── TextField.tsx
└── page.tsx
componentsに各々のField(input)タグごとに切り出して、ApplyFormでそれをまとめつつ、Context Providerで小コンポーネントにFormの機能を提供します。
全体像
次にいくつかの実装例を紹介していきます。
1. React Hook FormでFieldの実装した時
React Hook Formの実装
const FirstNameField = memo(function FirstNameFieldBase(): JSX.Element {
const { register, formState: { errors } } = useForm();
return (
<input
{...register('firstName', {
required: '名前は必須です',
maxLength: {
value: 20,
message: '20文字以内で入力してください'
}
})}
aria-invalid={errors.firstName ? "true" : "false"}
/>
);
});
2. 型安全性の確保
zodとの連携で型安全にsubmitできます。
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
firstName: z.string().min(1, '名前を入力してください'),
email: z.string().email('メールアドレスの形式が正しくありません'),
});
type FormData = z.infer<typeof schema>;
const form = useForm<FormData>({
resolver: zodResolver(schema),
});
3. 動的フォームの実装
動的フォームの実装も可能です。
const { fields, append, remove } = useFieldArray({
control,
name: "educations"
});
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`educations.${index}.schoolName`)} />
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
<button type="button" onClick={() => append({ schoolName: '' })}>
学歴を追加
</button>
</>
);
educations.${index}.schoolNameの名前でエラーのオブジェクトや最新のステータスが構築可能です。
drag and dropで順番入れ替えるのも可能です。
実装してみて感じた課題
開発を進めていく中で、いくつか課題が見えてきました。
上記に載せたようなシンプルな実装ならいいのですが、
フィールド間の依存関係が複雑なバリデーション
複数フィールドの相互依存するバリデーションの発火タイミングのコントロールと、あらかじめ決まっていた仕様通りのエラーメッセージの制御が思ったより大変でした。
1例を紹介するとエラーメッセージが長くなりすぎないように複数項目の関連するバリデーションだけれど、エラーメッセージ自体は一つ出すようにするのような内容です。
非同期データとの連携
- 初期値の設定とマスターデータの取得のタイミング制御が難しい
- 上記の具体例としてフォームの要素がフラッシュする。(一回別の値が表示されてその後に初期状態が表示される。。。ユーザー体験が悪い)
なって欲しい状態としては、常にselectボックスの中身は空文字になって欲しかったのですが、一瞬optionの値が表示されてその後に空文字になっていました。
これはdefaultValueにasyncを入れてpromiseの解決までの状態を遅延させるなど、それ以外にも状態を監視して読み込みが完了になるまでを待つなどをしたのですが
一回select boxのoptionsの配列の先頭の値が表示されてその後空白になるという挙動が解決できませんでした。
その時に検証したReact Hook Form Contextが提供する状態です。promiseの解決の時はisLoadingがtrueになるとasync(promise)のdefaultValueが解決された状態です。
isSubmitted boolean Set to true after the form is submitted. Will remain true until the reset method is invoked.
isSubmitSuccessful boolean Indicate the form was successfully submitted without any runtime error.
isSubmitting boolean true if the form is currently being submitted. false otherwise.
isLoading boolean true if the form is currently loading async default values.
参考までに
defaultValueにうまく値をセットするのは昔から議論されてたり色んな情報があるけど、結局解決できない。
※ 念の為今回検証しきれてないことも共有します。今回はFormProviderとuseFormContextを使って使って実装ました。仮説ではあるのですが、React Hook Formがrefを使ったコンポーネントの操作をしているのでrefのデータと同期させるところで値がフラッシュして表示されてしまっている可能性はあるかなと思いました。https://react-hook-form.com/advanced-usage#SmartFormComponent
React Hook Formのまとめ
useEffectとuseWatchとresetをごりごり使ってエラーや初期値をセットしてUX向上させることはできます。ただ、その状態でReact Hook Formの実装をチームメンバーにも見てもらったところ。後述するuseReducerによるシンプルな実装の方が読みやすいという結論になりました。
toCのwebなので今後もFormの最適化は進んでいくことも考えられます。
そうした時に調査実装時間の観点からもReact Hook Formを学んで毎回調査を隅々までして最適解を出すのも時間がかかる印象です。
useReducerによる実装
React Hook Formのまとめも踏まえて、ライブラリに依存しないでデータフローさえわかっていたらなんとかなるuseReducerによる実装をします。
下記にこれから実装する内容の全体像を貼っておきます!
FormContextTypeの設計
FormContextTypeは以下のような形で実装してみました。
type FormContextType = {
// フォームの状態
state: FormState;
// 状態更新用のディスパッチ
dispatch: Dispatch<FormAction>;
// 送信処理
handleSubmit: (e: React.FormEvent) => void;
// 値の設定とバリデーション
handleSetValueAndValidate: (
fieldName: keyof ApplyFormType,
value: ApplyFormType[keyof ApplyFormType],
) => void;
// 値の設定
handleSetValue: (
fieldName: keyof ApplyFormType,
value: ApplyFormType[keyof ApplyFormType],
) => void;
// バリデーション
handleValidate: (fieldName: keyof ApplyFormType) => void;
// 誕生日フィールド用
handleSetValueAndValidateBirthDay: (
field: 'year' | 'month' | 'day',
) => (value: string) => void;
// 卒業日フィールド用
handleSetValueAndValidateGraduation: (
field: 'year' | 'month' | 'status',
) => (value: string) => void;
};
Reducerの実装とエラーハンドリング
エラー処理はこんな感じで実装しました。
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'VALIDATE_FIELD': {
const result = applyFormSchema.safeParse(state.values);
if (!result.success) {
const fieldError = result.error.issues.find(
(issue) => issue.path[0] === action.field,
);
return {
...state,
errors: {
...state.errors,
[action.field]: fieldError?.message ?? '',
},
};
}
return {
...state,
errors: {
...state.errors,
[action.field]: '',
},
};
}
// その他のケース
}
}
今回のformではzodのerrorを一つだけ表示できたら良かったので相違処理もカスタマイズ可能です。
コンポーネントごとに魔改造も可能です。笑
コンポーネントの実装例
実際のフィールドコンポーネントはこのように実装しました。
const FirstNameRubyField = memo(function FirstNameRubyFieldBase(): JSX.Element {
const { state, handleSetValue, handleValidate } = useForm();
const { values, errors } = state;
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
handleSetValue('firstNameRuby', e.target.value);
},
[handleSetValue],
);
const onBlur = useCallback(() => {
handleValidate('firstNameRuby');
}, [handleValidate]);
return (
<TextField
name="firstNameRuby"
label="フリガナ"
required
onChange={onChange}
onBlur={onBlur}
value={values.firstNameRuby}
error={errors.firstNameRuby ?? ''}
maxLength={20}
/>
);
});
見てもらうとわかる通りvalidateとsetterを呼び出すタイミングは完全に具体のコンポーネントに任せるのでその前後に非同期処理を挟むのも柔軟に対応できます。
useReducerのまとめと採用した理由
実装を進める中で、useReducerには期待した通りではありますが以下のようなメリットを再確認できました。
-
状態管理の透明性
- Reducerパターンによる予測可能な状態更新
- デバッグがしやすい
- チーム内での理解・共有がスムーズ
-
カスタマイズの自由度
- プロジェクト固有の要件に合わせた実装が可能
- バリデーションロジックを完全にコントロール
- エラーハンドリングの柔軟な実装
-
学習コストとメンテナンス性
- React標準機能なので学習コストが低め
- 外部ライブラリへの依存が少ない
- コードの見通しが良い
今後の課題
useReducerでは動的なフィールド追加の仕組みは実装できてないのでそちらの検討です。
まとめ
useReducerでフォームを実装してみて、以下のような学びがありました。
- プロジェクトの要件に合わせた柔軟な実装ができた
- チーム内でのコードレビューや保守がしやすい
- 状態管理の透明性が高く、デバッグもしやすい
動的フィールドの実装などが要件に上がってきた時にはまだまだ改善の余地はありますが、現状の要件には十分対応できています。
React Hook Formを選ぶ場合は、React Hook Formができることをベースに仕様を作れるときは実装がスムーズになりそうと思いました。
HERP Careersでは時にはライブラリを使って時にはスクラッチで書いたり柔軟に技術を選べます!全部TypeScriptです!
Discussion