React・実務の道具立て

フォームはライブラリに任せる(React Hook Form + zod)

入力欄が増えると、自前のフォームは検証・エラー表示・リセットで一気に膨らみます。React Hook Formとzodで、検証ルールを1か所にまとめ、型もそこから作る書き方に置きかえます。

formsの回で作ったフォームは、入力欄3つですでに60行ありました。実務のフォームは10欄を超えることがあります。そうなると自前では、こうなります。

  • useStateが10個
  • 検証のif文が10個
  • エラーメッセージのstateが10個
  • リセットで10回setXxx('')

同じ形のコードが増えるだけなので、ここは道具に任せる場面です。

入れるもの

npm install --save-exact react-hook-form zod @hookform/resolvers
パッケージ役目
react-hook-form入力値・検証・エラー・送信中をまとめて管理
zod検証ルールを書く(そこから型も作れる)
@hookform/resolvers2つをつなぐ

検証ルールを1か所に書く

まずzodで「正しい形」を定義します。

import { z } from 'zod'

const schema = z.object({
  name: z.string().trim().min(1, '名前を入れてください').max(20, '20文字までにしてください'),
  area: z.string().trim().max(20, '20文字までにしてください'),
  emoji: z.string(),
})

type FormValues = z.infer<typeof schema>

最後の1行が効きます。z.inferで、検証ルールから型が作られます

// FormValues は { name: string; area: string; emoji: string } になる

検証と型を別々に書かないので、片方だけ直してずれる事故が起きません。

フォームにつなぐ

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { Bird } from '../types.ts'

type Props = {
  onAdd: (bird: Bird) => void
}

function AddBirdForm({ onAdd }: Props) {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { name: '', area: '', emoji: '🐧' },
  })

  function onSubmit(values: FormValues) {
    onAdd({ id: crypto.randomUUID(), ...values, area: values.area || 'ひみつ', rare: 1, memo: '' })
    reset()
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="text" {...register('name')} />
      {errors.name && <p className="text-red-600">{errors.name.message}</p>}

      <input type="text" {...register('area')} />
      {errors.area && <p className="text-red-600">{errors.area.message}</p>}

      <button type="submit" disabled={isSubmitting}>ずかんに追加</button>
    </form>
  )
}

自前版と比べて、消えたものを並べます。

自前で書いていたものどうなったか
useState×4消えたuseFormが持つ)
valueonChange{...register('name')}の1つ
検証のifzodのスキーマ
エラー用のstateformState.errors
送信後のsetXxx('')×3reset()の1回に

入れる/入れないの線引き

状況判断
入力が2〜3個・検証も単純自前のuseStateで十分
検証ルールが増えてきた入れる
同じ検証をサーバーでも使いたい入れる(zodのスキーマを共有する)
フィールドを動的に増減する(入力欄の追加ボタン)入れる
入力欄が多くて重い入れる
べんりワザ入力チェック(バリデーション)の基本ブラウザ標準の入力チェックはこちら。ライブラリと役割が違うので、両方知っておくと選べます

公式ドキュメントも見てみる

React Hook Form外部サイトGet Started | React Hook FormReact Hook Form公式のGet Started。registerhandleSubmitformStateの最小の形がまとまっています(英語)

このレッスンのまとめ

  1. 自前フォームは入力欄の数だけコードが増える(state・検証・エラー・リセット)
  2. React Hook Formが入力値・検証・エラー・送信中をまとめて持つ
  3. zodで検証ルールを1か所に書き、z.inferで型も作る(ずれない)
  4. 型は書いている最中、zodは動いているとき——役割が違う
  5. {...register('name')}制御コンポーネントの4点セットを1行にしたもの
  6. 入力のたびに再描画しないので、欄が多い画面でも重くなりにくい
  7. 入力2〜3個なら自前で十分。検証が増えた・サーバーと共有したいときに入れる

次はテストです。書いたものが壊れていないことを、機械に確かめてもらいます。

やってみよう:フォームをライブラリに置きかえる

AddBirdFormをReact Hook Form + zodで書き直します。

  1. npm install --save-exact react-hook-form zod @hookform/resolvers
  2. schemaをzodで書く(名前は必須・20文字まで)
  3. useState4つをuseFormに置きかえる
  4. <input>{...register('name')}の形にする
  5. エラー表示をerrors.name?.messageに変える
  6. 送信後の初期化をreset()にする
  7. 空のまま送信して検証メッセージが出ること、送信後に空になることを確認する
const schema = z.object({
  name: z.string().trim().min(1, '名前を入れてください').max(20, '20文字までにしてください'),
  area: z.string().trim().max(20, '20文字までにしてください'),
  emoji: z.string(),
})

置きかえたあと、formsの回で書いたテストがそのまま通るかも見てください。ラベルとボタンの文言を変えていなければ通るはずです。中身を入れかえてもテストが通る——これがふるまいをテストしていることの証拠になります。

よくある質問

React Hook Formは何をしてくれますか?
入力値の保持、検証、エラーの管理、リセット、送信中の状態をまとめて引き受けます。入力のたびに再描画しない作りなので、入力欄が多い画面でも重くなりにくいのも利点です。
zodは何のために使いますか?
検証ルールを1か所に書くためです。書いたルールから型も作れるので、検証と型がずれません。サーバー側でも同じルールを使い回せます。
小さなフォームでも入れるべきですか?
入力が2つ3つなら自前のuseStateで十分です。検証ルールが増えてきた、同じ検証をサーバーでも使いたい、という段階で入れると効きます。
React 19のフォーム機能があれば不要ですか?
用途が重なる部分はありますが、複雑な検証やフィールドの動的追加はライブラリのほうが強いです。フレームワークのサーバー処理と組み合わせるかどうかで選び分けます。