© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsform

Zustand Test の Zod / RHF watch / patchFormData と setFormData を読み解く

RHF の watch でリアルタイム同期しつつ、保存時に Zod parse で確定値を書き戻す Playground のフォーム実装を1ステップずつ解説する。

難易度: 初級·更新: 2026-05-07·
zodreact-hook-formzustand

対応バージョン

nextjs 15react 19zod 4react-hook-form 7

前提環境

React Hook Form の useForm と register の基本を理解していること

概要

Zustand Test のフォームは、入力中は リアルタイムで store に反映 しつつ、保存ボタンで Zod parse を通した確定値で再書き込み します。役割は次のように分かれています。

  • React Hook Form (watch): 入力中の値を1キーストロークごとに通知
  • patchFormData: 通知された部分値を settingsStore に即時マージ(Zod は通らない)
  • setFormData: 保存ボタン押下時、Zod parse 済みの確定値で 一括上書き
  • Zod (coerce 含む): 文字列入力を数値などに正規化しつつ検証

patchFormData の入力はバリデーション通過前なので、呼び出し側で正規化・クランプの責任を持つ点に注意します。

インストール

npm install zod react-hook-form @hookform/resolvers zustand

Zod スキーマと coerce

スキーマがフォームの仕様の source of truth になります。<input type="number"> から来る値は文字列なので、数値フィールドは z.coerce.number() で型変換ステップを噛ませます。

// src/schemas/formSchema.ts
import { z } from "zod";

export const formSchema = z.object({
  name:  z.string().min(1, "名前を入力してください"),
  email: z.string().email("有効なメールアドレスを入力してください"),
  rate:  z.coerce
    .number()
    .min(0,   "0 以上で入力してください")
    .max(100, "100 以下で入力してください"),
});

export type FormData = z.infer<typeof formSchema>;

押さえどころは2つです。

  • z.coerce.number() は内部で Number(value) を呼んでから検証する。<input value="42"> のような string をそのまま渡せる
  • z.infer<typeof formSchema> で型を導出。スキーマと型が必ず一致する

React Hook Form の watch でリアルタイム同期

useForm の watch を useEffect と組み合わせると、入力ごとに settingsStore を更新できます。watch のコールバックは 検証前の生値 を返すため、patchFormData 側で正規化を行います。

// src/components/SettingsForm.tsx
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";
import { useSettingsStore } from "@/store/settingsStore";

export function SettingsForm() {
  const patchFormData = useSettingsStore((s) => s.patchFormData);
  const setFormData   = useSettingsStore((s) => s.setFormData);

  const {
    register,
    watch,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(formSchema),
  });

  // 入力のたびに store へ部分マージ
  useEffect(() => {
    const sub = watch((value) => {
      patchFormData({
        name:  value.name  ?? "",
        email: value.email ?? "",
        rate:  Math.min(100, Math.max(0, Number(value.rate) || 0)),
      });
    });
    return () => sub.unsubscribe();
  }, [watch, patchFormData]);

  const onSubmit = (data: FormData) => {
    // data は Zod parse 済み = 確定値
    setFormData(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="number" {...register("rate")} />
      {errors.rate && <p>{errors.rate.message}</p>}

      <button type="submit">保存</button>
    </form>
  );
}

watch から返るのは「入力中の現在値」であり、未入力フィールドは undefined になることがあります。patchFormData に渡す前に デフォルト値・クランプ を済ませるのは、patchFormData が Zod を通らないためです。

patchFormData と setFormData の使い分け

両方とも set を呼びますが、入口が違うので責務が異なります。

patchFormData(partial)setFormData(values)
呼ばれるタイミング1 文字入力するたび保存ボタン押下時
引数の検証Zod を通っていない(呼び出し側で正規化)Zod parse 済みの確定値
書き込み形式set(partial) で部分マージset(values) で一括上書き
エラー UI出さない(途中入力なので)RHF が errors.<field>.message を表示
store の整合性「入力中の最新値」「最後に確定された値」

この分離により、入力中も Store Monitor では即座に値の変化が見え、保存後は Zod を通過した値だけが残る、という体験を両立できます。

エラー表示は Zod スキーマ任せ

エラーメッセージはスキーマの第 2 引数に書きます。UI は errors.<field>.message を表示するだけです。表示ロジックを 1 箇所にまとめると、文言修正が UI 全体に行きわたります。

{errors.rate && <p className="text-red-500">{errors.rate.message}</p>}

zodResolver が Zod のエラーを RHF の errors に変換してくれるので、UI 側は普段の RHF と書き味が変わりません。

ポイント

  • 数値フィールドは z.coerce.number() を使う。<input> の文字列値をそのまま受けられる
  • 入力中は watch → patchFormData で リアルタイム同期。Zod を通らないので呼び出し側で正規化する
  • 保存時は handleSubmit(onSubmit) の data が Zod parse 済み なので、そのまま setFormData に渡せる
  • store の値は「patchFormData で書かれた中間値」と「setFormData で書かれた確定値」が同じ形を共有するため、Store Monitor は分岐なしで両方を表示できる
  • エラーメッセージはスキーマ側に集約。UI は errors.<field>.message を出すだけにする

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • Zustand 編 — 4 store の分担と storeSync の話
  • TypeScript 編 — z.infer から各 store の型まで
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground の3記事のうち Zod 編。patchFormData は Zod を通さず正規化済み値を期待し、setFormData は Zod parse 済みの確定値だけを受け取る、という使い分けを理解するための記事。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の React Hook Form を watch・handleSubmit・formState で読み解く

    useForm の watch でリアルタイム同期し、handleSubmit で確定値を扱い、formState で UI を切り替える Playground の RHF 実装を1ステップずつ解説する。

  • Zustand Test の Zod スキーマを refine と superRefine で拡張する

    単体フィールドでは表現できない条件付き検証や cross-field バリデーションを refine / superRefine / discriminatedUnion で書き分ける手順を解説する。

  • React Hook Form の useFieldArray で動的フィールドを追加・削除する

    useFieldArray を使い、フォーム内のフィールドを動的に追加・削除・並び替えする実装例。Zod で配列バリデーションも行う。

  • React Hook Form でファイルアップロード対応フォームを実装する

    React Hook Form の register でファイル入力を管理し、Zod でファイルサイズ・拡張子バリデーションを行う実装例。

  • Zod スキーマをフォームバリデーションと API レスポンス検証で共有する

    同一の Zod スキーマを React Hook Form のバリデーションと fetch レスポンス検証の両方で再利用し、型定義の重複をなくす実装例。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
  • LibraryZodTypeScript ファーストのスキーマバリデーションライブラリ。型推論と実行時バリデーションを同一スキーマで行える。
←サンプル一覧に戻る