責務の分け方
react-hook-form が担う(フォーム内部):
・入力値(register で管理)
・バリデーションエラー(errors)
・フィールドの dirty / touched 状態
・送信中フラグ(isSubmitting)
・フォーム全体の valid / invalid 状態
Zustand が担う(フォーム外の共有状態):
・submit 成功後に他のコンポーネントと共有するデータ
・ページをまたいで維持したい選択条件・フィルタ設定
・モーダルの開閉など、フォームとは独立した UI 状態
判断の起点: 「フォームが送信されるまで外に出す必要があるか?」
No → react-hook-form に閉じる
Yes → Zustand に移す(submit 後にアクションを起こす)
フォーム入力値を store に常駐させない理由
// ❌ onChange のたびに store に反映するパターン
function SearchForm() {
const setQuery = useSearchStore((s) => s.setQuery);
return (
<input
onChange={(e) => setQuery(e.target.value)} // キーストロークごとに store が更新される
/>
);
}
// → store を購読しているすべてのコンポーネントが毎キーストロークで再レンダリングされる
// → バリデーション・isDirty・リセットも自前実装が必要になる
// ✅ react-hook-form で入力値を管理する(非制御コンポーネント)
function SearchForm() {
const { register, handleSubmit } = useForm<{ query: string }>();
const setLastQuery = useSearchStore((s) => s.setLastQuery);
function onSubmit(data: { query: string }) {
setLastQuery(data.query); // submit 後にだけ store に移す
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("query")} />
<button type="submit">検索</button>
</form>
);
}
react-hook-form は非制御コンポーネントベースで動くため、入力中は DOM が値を保持し、React の state や store に反映されません。これが「キーストロークごとの再レンダリングを発生させない」設計の根拠です。
submit 後に store へ結果を反映するパターン
// submit 成功後に store に移す典型パターン
type UserStore = {
profile: UserProfile | null;
setProfile: (profile: UserProfile) => void;
};
const useUserStore = create<UserStore>((set) => ({
profile: null,
setProfile: (profile) => set({ profile }),
}));
function ProfileEditForm() {
const { register, handleSubmit, reset } = useForm<ProfileFormData>();
const setProfile = useUserStore((s) => s.setProfile);
async function onSubmit(data: ProfileFormData) {
const saved = await api.updateProfile(data); // API 送信
setProfile(saved); // submit 後にだけ store に反映
reset(); // フォームをリセット
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
<button type="submit">保存</button>
</form>
);
}
// 別コンポーネントから store を参照
function Header() {
const profile = useUserStore((s) => s.profile);
return <span>{profile?.name}</span>;
}
ポイント: 入力中の値は react-hook-form 内に留まり、submit が完了して初めて store に移ります。「入力中」と「確定済み」を明確に分けることで、reset やバリデーションの管理が単純になります。
wizard(ステップフォーム)での境界
ステップ間でデータを共有する wizard フォームは、境界が曖昧になりやすい場面です。
// Zustand で wizard のステップデータを保持する例
type WizardStore = {
step1Data: Step1Data | null;
step2Data: Step2Data | null;
setStep1: (data: Step1Data) => void;
setStep2: (data: Step2Data) => void;
reset: () => void;
};
const useWizardStore = create<WizardStore>((set) => ({
step1Data: null,
step2Data: null,
setStep1: (data) => set({ step1Data: data }),
setStep2: (data) => set({ step2Data: data }),
reset: () => set({ step1Data: null, step2Data: null }),
}));
// 各ステップは react-hook-form で入力を管理
function Step1Form() {
const { register, handleSubmit } = useForm<Step1Data>();
const setStep1 = useWizardStore((s) => s.setStep1);
function onSubmit(data: Step1Data) {
setStep1(data); // ステップ完了時にだけ store に保存
goToStep(2);
}
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}
// 最終ステップで全データを送信
function Step3Form() {
const { step1Data, step2Data, reset } = useWizardStore();
const { handleSubmit } = useForm<Step3Data>();
async function onSubmit(step3Data: Step3Data) {
await api.submit({ ...step1Data, ...step2Data, ...step3Data });
reset(); // wizard 完了で store をクリア
}
...
}
wizard での判断基準:
- 各ステップの入力中の値 → react-hook-form(各ステップの form 内)
- ステップが完了したデータ → Zustand(ステップ間の橋渡し)
- wizard 完了後 → store をリセット、必要なら API レスポンスを別 store に移す
フィルタ・検索条件の扱い
検索フォームのフィルタ条件は「入力中」と「適用済み」で扱いを変えます。
// 適用済みの検索条件は Zustand(ページ跨ぎで維持)
type SearchStore = {
appliedQuery: string;
appliedCategory: string;
applyFilter: (query: string, category: string) => void;
};
function SearchForm() {
const { register, handleSubmit } = useForm<FilterFormData>();
const applyFilter = useSearchStore((s) => s.applyFilter);
function onSubmit(data: FilterFormData) {
applyFilter(data.query, data.category); // 送信後に store に適用
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("query")} placeholder="キーワード" />
<select {...register("category")}>...</select>
<button type="submit">絞り込む</button>
</form>
);
}
// 別コンポーネントが store から適用済み条件を参照
function ResultList() {
const { appliedQuery, appliedCategory } = useSearchStore();
// appliedQuery / appliedCategory でデータを取得・表示
}
Server Actions / useActionState との接続
Server Actions と react-hook-form を組み合わせる場合、バリデーションエラーの返し方が選択肢になります。
// パターン A: react-hook-form でクライアントバリデーション + Server Action でサーバー処理
function LoginForm() {
const { register, handleSubmit, setError, formState: { errors } } = useForm<LoginData>();
async function onSubmit(data: LoginData) {
const result = await loginAction(data);
if (result?.error) {
setError("root", { message: result.error }); // サーバーエラーを form に返す
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <p className="text-red-500">{errors.root.message}</p>}
<input {...register("email")} />
<input {...register("password", { required: true })} />
<button type="submit">ログイン</button>
</form>
);
}
// パターン B: useActionState でサーバーバリデーションを UI に返す(react-hook-form なし)
// → server-action-validation-pattern を参照
使い分け:
- クライアントで即時フィードバックが必要なバリデーション → react-hook-form
- サーバー側でしか検証できないバリデーション(DB の重複チェックなど)→ Server Action の戻り値を
setErrorで form に返す
よくある誤用まとめ
| 誤用 | 正しい判断 |
|---|---|
| onChange のたびに store を更新 | submit 後にだけ store に移す |
| バリデーションエラーを store で管理 | react-hook-form の errors に任せる |
useFormContext をフォーム外の共有に使う | フォーム外の共有は Zustand |
| wizard の入力中データをすべて store に置く | 入力中は form、ステップ完了後に store |
| submit 済みデータと入力中データを同じ場所に置く | 分けることでリセット・再送信が単純になる |
store 設計の全体像 → Zustand / store 設計パターン
global store と private state の使い分け → グローバル store と private state の使い分け
Server Actions + Zod バリデーションパターン → server-action-validation-pattern