TOOLS BOX/ガイド/Zustand と react-hook-form の境界
Concept

Zustand と react-hook-form の境界

フォーム内部状態(入力値・バリデーション・dirty 状態)は react-hook-form に、フォーム外の共有状態(送信結果・ページ跨ぎの選択条件)は Zustand に任せるという責務分離を整理するガイド。wizard フォームや submit 後の store 反映など境界が曖昧になりやすい場面の判断基準を説明する。

zustandreact-hook-formformstate-managementform-stateglobal-statewizardsubmit

どういう場面で使うか

  • ·フォーム入力値・バリデーションエラー・isDirty・isSubmitting は react-hook-form で管理する
  • ·submit 成功後にフォーム外のコンポーネントと共有する必要があるデータは Zustand に移す
  • ·wizard(ステップフォーム)でステップ間のデータを保持する場合は Zustand が候補になる
  • ·フィルタ条件やソート設定など、フォーム送信とは独立してページ跨ぎで維持したい UI 状態は Zustand

注意点 / Pitfalls

  • ·フォームの入力値を onChange のたびに store に反映すると、キーストロークごとに store が更新され不必要な再レンダリングが広がる
  • ·バリデーションエラーを store で管理すると、react-hook-form の errors と二重管理になる
  • ·wizard フォームのステップデータをすべて store に置くと、ブラウザバックやリロード時の状態管理が複雑になる
  • ·submit 後の「送信済みデータ」と「フォームの入力値」を同じ場所に置くと、リセット処理が難しくなる
  • ·useFormContext を store の代替として使いすぎると、form 外のコンポーネントが form ツリーに強く依存する

何と混同しやすいか

補足

react-hook-form はフォームの内部状態に特化した非制御コンポーネントベースのライブラリ。Zustand はフォーム外の共有状態に特化。両者の責務は重複しない。境界は「フォームが送信されるまで外に出す必要があるか」で判断する。

責務の分け方

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

関連ドキュメント

関連サンプル

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