概要
Zustand Test では、React Hook Form を 「入力中の一時状態を扱う層」 に限定し、Zustand を 「確定値を保持する層」 として切り分けています。 RHF の API 単位で見ると、それぞれ次の役割を担っています。
| API | Playground での役割 |
|---|---|
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) — 完成例と外部デモ