概要
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) — 完成例と外部デモ