失敗から学ぶ React Hook Form + Zod を使った相関バリデーションの実装
React Hook Fomr + Zod を使ったフォームで「相関バリデーション」を実装した際に、初手からうまくいかずに試行錯誤しながら実装しました。
失敗ケースとそれをどう解消するかをまとめます。
実装するもの
- Inputは3つ
- A: テキストフィールドで、1文字以上を入力必須。
- B: numberフィールドで、デフォルト0。B+C が3以上の場合に有効。(いわゆる相関チェック、相関バリデーション)
- C: numberフィールドで、デフォルト0。
- バリデーションエラーの場合は、各Inputの隣にエラーメッセージを表示
- バリデーションエラーでない場合は、submitボタンを押せる
土台となるフォーム実装
まずはベースとして、バリデーションなしのフォームを実装しましょう。
npm パッケージ
control を受け取り利用する Input コンポーネントの実装
※ any は、サンプルとして汎用性のあるものを楽して用意するために、妥協して使っています。
テキストフィールド
numberフィールド
スキーマ
useForm を呼び出すカスタムフック
フォーム表示
バリデーションなしのフォームが完成しました。
バリデーション実装
バリデーションを実装してみます。
フックやフォームを表示するコンポーネントはそのままで、スキーマだけ変更します。
動作確認します。
失敗ケース① エラーになると、transform を通らない
動作確認してみると「プロパティB, C」が要件を満たしたとしても、「プロパティA」が空でエラーの場合には「プロパティB」のエラー表示が消えないことがわかります。
これはメソッドチェーンの根元のz.object({})
でエラーになっている場合、transform
を通らずにrefine
まで到達するためです。
ドキュメントではそういった仕様であることの説明が見つかりませんでしたが、console.log
をtransform
に仕込んでみたところそういう動作になっていることがわかりました。
「プロパティA」のエラーが「プロパティB, C」に影響しないように、次のようにネストした object に変更してみることにします。
z
.object({
a: z.string(),
numbers: z
.object({
b: z.string(),
c: z.string(),
})
...
※ ちなみに、transform
を通らない場合にrefine
に至ってほしくないだけであれば、refine
の前にcatch(() => z.NEVER)
を挟むだけとかでもよいです。
スキーマをネストした object に変更
先程の反省をコードに反映します。
まずスキーマを次のようにします。
refine
のpath
は['b']
のままにしておくことが注意点です。
この書き方でエラーになった際のpath
は['numbers', 'b']
になるようです。
ドキュメントには appended to error path
としか書いてなかった(と思う)ので、ミスりやすいポイントです。
伴って、フックのデフォルト値を変更します。
Inputコンポーネントの呼び出しも次のように変更します。
変更が完了したので、動作確認をします。
先程の問題は解消しました。「プロパティA」が空であっても、「プロパティB, C」のエラー表示は適切になっています。
失敗ケース② 相関バリデーションのエラーが表示されない場合がある
ただし今度は別の問題が見つかります(正確には、失敗ケース1の時から起きている事象ですが)。
「プロパティC」を変更し「プロパティB, C」が有効になるようにしても、「プロパティB, C」のエラー表示が消えません。
ただ submit ボタンは押せるようになっているので、formState は適切に更新されているようです。
どうやら「プロパティC」が変更されても「プロパティB」は再レンダリングされないようです。
次のようにController
の render 関数 にconsole.log
を仕込むとそれが確認できます。
export default function NumberInput(props: {
name: any;
control: Control<FieldValues, any>;
}) {
return (
<Controller
name={props.name}
control={props.control}
render={({ field, fieldState: { error } }) => {
// ここ
console.log(props);
return (
<div>
<label>プロパティ{props.name}: </label>
<input type="number" {...field} />
{error?.message}
</div>
);
}}
/>
);
}
こういうときは、triggerを使うと良さそうです。
trigger を使う
まずは Input コンポーネントを trigger
を受け取るものに変更します。
最後に、フックが返すtrigger
を Input コンポーネントに渡します。
完成しました。
動作確認すると、正常に動いてることがわかります。
終わりに
今回利用したコードはこちらにあります。
Discussion