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

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

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

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

難易度: 初級·更新: 2026-05-07·
react-hook-formzodzustand

対応バージョン

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

前提環境

useForm と register の基本的な使い方を理解していること

概要

Zustand Test では、React Hook Form を 「入力中の一時状態を扱う層」 に限定し、Zustand を 「確定値を保持する層」 として切り分けています。 RHF の API 単位で見ると、それぞれ次の役割を担っています。

APIPlayground での役割
useFormフォーム全体の状態を抱える本体
watch入力1キーごとに settingsStore.patchFormData へ同期
handleSubmit保存ボタン経由で settingsStore.setFormData へ Zod parse 済みの値を渡す
formStateエラー・dirty / isSubmitting を見て UI を切り替える
reset「全ストアをリセット」と連動してフォームも初期値へ戻す

ポイントは、RHF と store は一方向にだけ流れる ということです。store → RHF は基本的に書き戻しません(reset のみ例外)。

インストール

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

useForm に Zod スキーマを差す

useForm<FormData> のジェネリクスに Zod から導出した型を渡し、resolver: zodResolver(formSchema) で検証エンジンを Zod に差し替えます。これだけで register のキー補完と errors の型が一括で確定します。

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

export function SettingsForm() {
  const {
    register,
    watch,
    handleSubmit,
    reset,
    formState: { errors, isDirty, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: { name: "", email: "", rate: 0 },
  });
  // ...
}

defaultValues を 明示する のが重要です。watch で取れる値が undefined になるのを避けられます。

watch で settingsStore に部分マージする

watch(callback) を useEffect に閉じ込めて購読し、戻り値の unsubscribe を cleanup で必ず呼びます。

import { useEffect } from "react";
import { useSettingsStore } from "@/store/settingsStore";

const patchFormData = useSettingsStore((s) => s.patchFormData);

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]);

watch(callback) は 検証前の生値 を返します。型上 value.rate は number | undefined になり得るため、patchFormData の側で 正規化・クランプ を行います。 watch() を引数なしで呼ぶ「購読型」もありますが、毎レンダリングごとに値が走るためコストが高めです。Playground では コールバック型 を使い、必要なときだけ走らせます。

handleSubmit で確定値を扱う

handleSubmit は 検証 OK のときだけ onSubmit を呼びます。引数の data は FormData 型 = Zod 通過済みです。そのまま setFormData に渡せます。

const setFormData = useSettingsStore((s) => s.setFormData);

const onSubmit = (data: FormData) => {
  setFormData(data);
};

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    {/* ... */}
  </form>
);

watch 経由ではバリデーションを走らせていないため、patchFormData に書き込まれているのは「途中入力」かもしれません。確定値を Store Monitor 等で「保存済み」として扱いたいときは setFormData 経由の値だけを見ます。

formState で UI を切り替える

formState から errors / isDirty / isSubmitting を取り出し、UI 状態を分岐します。エラー表示はスキーマ側のメッセージをそのまま流します。

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register("name")} aria-invalid={!!errors.name} />
    {errors.name && <p className="text-red-500">{errors.name.message}</p>}

    <input {...register("email")} aria-invalid={!!errors.email} />
    {errors.email && <p className="text-red-500">{errors.email.message}</p>}

    <input type="number" {...register("rate")} />
    {errors.rate && <p className="text-red-500">{errors.rate.message}</p>}

    <button type="submit" disabled={!isDirty || isSubmitting}>
      {isSubmitting ? "保存中…" : "保存"}
    </button>
  </form>
);
  • isDirty で「入力に変化があるとき」だけ保存ボタンを有効化する
  • isSubmitting で多重送信を防ぐ

formState のフィールドは 必要なものだけ destructure することが推奨です。RHF の最適化で、参照したフィールドが変化したときだけ再レンダリングされます。

reset で「全ストアをリセット」と連動する

ヘッダーの「全ストアをリセット」ボタンは store の reset と RHF の reset を 両方とも 呼びます。フォームの入力欄と Zustand の保持値、両方が初期値に戻ります。

"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { useProfileStore }  from "@/store/profileStore";
import { useDetailStore }   from "@/store/detailStore";
import { useUiStore }       from "@/store/uiStore";
import { useFormContext }   from "react-hook-form";
import type { FormData } from "@/schemas/formSchema";

const INITIAL_FORM: FormData = { name: "", email: "", rate: 0 };

export function ResetAllButton() {
  const { reset } = useFormContext<FormData>();

  const onClick = () => {
    useSettingsStore.getState().reset();
    useProfileStore.getState().reset();
    useDetailStore.getState().reset();
    useUiStore.getState().reset();
    reset(INITIAL_FORM); // ← フォームの DOM も初期値に戻す
  };

  return <button onClick={onClick}>全ストアをリセット</button>;
}

useFormContext を使うために、フォーム側を FormProvider でラップしておく必要があります。reset(INITIAL_FORM) を store のリセットと同じタイミングで呼ぶ ことで、<input> の value と store の値が乖離するのを防ぎます。

ポイント

  • useForm は 入力中の一時状態 だけを担当する。確定値は Zustand に持たせる
  • watch(callback) は useEffect に閉じて cleanup で sub.unsubscribe() を呼び、値は 検証前 なので呼び出し側で正規化・クランプする
  • handleSubmit(onSubmit) の data は Zod parse 済みの確定値。そのまま setFormData に渡せる
  • formState は使うフィールドだけ destructure する。isDirty / isSubmitting で保存ボタンの活性を切り替える
  • 「全ストアをリセット」は store の reset と RHF の reset(INITIAL_FORM) を 両方 呼ぶ。input と store の乖離を防ぐ

関連記事

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

  • Zustand 編 — 受け取る側の store 設計
  • Zod 編 — coerce と patchFormData / setFormData の使い分け
  • TypeScript 編 — FormData 型の作り方と流し方
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground の追加記事。RHF が「入力中の状態」を、Zustand が「確定後の値」を持つという役割分離を実コードで読み解く。

リポジトリを見る

関連サンプル

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

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

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

  • Zustand Test の型を Zod スキーマから 4 ストアまで貫く

    Zod スキーマ → z.infer → useForm → settings / profile / detail / ui の 4 ストアまで、Playground の TypeScript の流れを1ステップずつ追う。

  • Zustand Test のフォームエラーを aria-invalid と aria-describedby で読み上げ可能にする

    RHF の errors.<field>.message を、aria-invalid と aria-describedby で input と紐付けてスクリーンリーダーに伝える書き方を解説する。

  • Zustand Test の watch 同期コストを計測して throttle で抑える

    RHF の watch から settingsStore に毎キーストローク同期する負荷を performance.mark で計測し、必要なら throttle / 差分検知で抑える手順を解説する。

  • Zustand Test の Form / Monitor / TabBar を Storybook で分離する

    Zustand に依存する UI を Storybook で取り扱うために、store のリセット / decorator / play function を組み合わせて状態をストーリー単位で固定する手順を解説する。

関連仕様

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

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