本文へスキップ
ウェブエンジニア問題集
第13章

React Hook FormとZodでフォームを検証する

約6分
この章の目次開く

ここまで書いてきたスキーマを、実際の入力画面につなぎます。Reactでフォームを扱うときの定番であるReact Hook Formには、Zodのスキーマをそのまま検証に使う仕組みが用意されています。

必要なパッケージ

React Hook Form本体と、Zodをつなぐアダプタ(resolver)を入れます。

npm install react-hook-form @hookform/resolvers
bash

@hookform/resolvers はZod以外のライブラリにも対応した共通パッケージで、Zod用の関数はサブパスから読み込みます。

import { zodResolver } from '@hookform/resolvers/zod';
ts

基本形

スキーマを1つ書いて、useForm の resolver に渡すだけです。

'use client';
 
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
 
const schema = z.object({
  name: z.string().trim().min(1, '名前を入力してください'),
  email: z.email('メールアドレスの形式が正しくありません'),
  age: z.coerce.number().int().min(0, '0以上で入力してください'),
});
 
type FormValues = z.input<typeof schema>;
 
export function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });
 
  return (
    <form onSubmit={handleSubmit(async (values) => { await save(values); })}>
      <input {...register('name')} />
      {errors.name && <p>{errors.name.message}</p>}
 
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
 
      <input {...register('age')} />
      {errors.age && <p>{errors.age.message}</p>}
 
      <button disabled={isSubmitting}>送信</button>
    </form>
  );
}
tsx

errors.name.message には、スキーマに書いたメッセージがそのまま入ります。入力欄への振り分けは path を見て自動で行われるので、自分で紐づける必要はありません。

検証ルールの定義がスキーマ1か所に集まり、画面側は表示だけを担当する形になります。

フォームの型には z.input を使う

useForm<FormValues> に渡す型は、型推論の章で扱った z.input です。z.infer ではありません。

理由は、フォームが扱うのが検証を通す前の値だからです。

const schema = z.object({
  age: z.coerce.number(), // 入力は string、出力は number
  role: z.string().default('user'), // 入力では省略可、出力では必須
});
ts
型agerole
z.inputunknown(文字列を渡す)省略可
z.infer(= output)number必須

z.infer を使うと、<input> が返す文字列と型が噛み合わず、初期値も入れられません。フォームは input、送信後の処理は infer——と覚えてください。

学習者学習者

handleSubmit に渡ってくる値はどっちの型なの?

先生先生

検証を通った後だから、実体は変換後の値だよ。age は数値になっている。型注釈と実体がずれて気持ち悪ければ、handleSubmit の中でもう一度 schema.parse(values) を通して、z.infer の型で受け直すのが確実だね。

パソコンで作業している人のイラスト

サーバー側の検証を省略しない

ここが最重要です。フォームの検証はブラウザ上で動いているだけなので、開発者ツールやcurlで簡単に回避できます。

// app/actions.ts
'use server';
 
import { z } from 'zod';
 
const schema = z.object({
  name: z.string().trim().min(1),
  email: z.email(),
});
 
export async function saveProfile(input: unknown) {
  const result = schema.safeParse(input); // サーバーでも必ず検証する
  if (!result.success) {
    return { ok: false, errors: z.flattenError(result.error).fieldErrors };
  }
  await db.save(result.data);
  return { ok: true };
}
ts

スキーマは共通ファイルに置いて、クライアントとサーバーの両方から読み込むのが定石です。同じルールが二重に書かれることを防げます。

src/schemas/profile.ts   ← スキーマの置き場所
  ├─ ProfileForm.tsx(クライアント)から import
  └─ actions.ts(サーバー)から import

よくあるハマりどころ

数値の入力欄で型エラーが出る

<input> の値は常に文字列です。z.number() ではなく z.coerce.number() を使うか、valueAsNumber を使います。ただし空欄が 0 になる落とし穴があるので、変換の章の対処を併せて確認してください。

エラーメッセージが英語で出る

スキーマ側にメッセージを書いていない箇所です。.min(1, 'メッセージ') のように、表示する可能性のある検証にはメッセージを添えます。

チェックボックスやセレクトの初期値で型が合わない

z.input を使っているか確認してください。.default() を使っているスキーマで z.infer を指定すると、初期値を省略できません。

パスワード確認のエラーが表示されない

オブジェクト全体に掛ける refine は path の指定が必須です。refineの章のとおり、path: ['confirm'] を書かないとどの入力欄にも紐づきません。

ちゃんと使うためのポイント

  • zodResolver(schema) を useForm に渡すだけで、検証とエラーの振り分けが繋がる
  • フォームの型は z.input。z.infer だと入力前の値と型が噛み合わない
  • エラーメッセージはスキーマ側に書く。画面は表示だけを担当する
  • フロントの検証はUXのため、サーバーの検証は安全のため。後者は省略できない
  • スキーマは共通ファイルに置き、クライアントとサーバーで共有する

次の章では、フォーム以外の入り口——APIの入力とレスポンスの検証 を扱います。

参考リンク

TypeScriptクイズに挑戦するこの章で学んだ型と検証の知識を、4択クイズでアウトプットして定着させよう