Playbook

Zustand Playground を作る

Zustand、React Hook Form、Zod、store間同期を、動くPlaygroundとして組み立てる手順。

zustandreact-hook-formzodplaygroundexternal-demo

このPlaybookのコード例は、外部Playgroundの設計を理解するために簡略化した例です。実装の完全なコードは外部Demoリポジトリ側で管理します。

このPlaybookで作るもの

Zustand を中心に、React Hook Form・Zod・store 間同期を組み合わせた Playground を構築します。フォームで入力し、確定後に別 store へ投影され、subscribe で変化を監視するまでの一連の流れを動作確認できる実験場です。

  • Zustand store の定義と selector による購読
  • React Hook Form と Zod によるフォームバリデーション
  • onSubmit で Zustand store へ確定値を保存
  • subscribe で store 間の状態を同期
  • devtools で状態変化を可視化

完成例

完成例は外部 Vercel デプロイで確認できます。フォーム入力・store 投影・store 間同期・デバッグパネルまで含めて実装済みです。

外部デモを見る

完成例ページは /builds/zustand-playground にあります。

Build を見る

GitHub リポジトリは現在準備中(private)です。動作確認は外部デモから行えます。

5 つのフェーズに分けて、この Playground を段階的に構築します。

フェーズゴール
Phase 1Zustand store の最小構成が動く
Phase 2React Hook Form + Zod でフォーム入力を確定できる
Phase 3確定値を表示用 store へ投影できる
Phase 4subscribe で store 間を同期できる
Phase 5devtools でデバッグし、外部デモとして公開する

データの流れ

フォーム入力
  ↓
React Hook Form で入力状態を管理
  ↓
Zod でバリデーション
  ↓
onSubmit で Zustand store に保存
  ↓
subscribe で別 store に同期
  ↓
selector で UI を更新

Phase 1: Zustand storeの最小構成

ゴール: create で store を定義し、コンポーネントから読み書きできる状態にする。

store の定義

// src/store/formStore.ts
import { create } from "zustand";

type FormState = {
  name: string;
  email: string;
  setName: (name: string) => void;
  setEmail: (email: string) => void;
};

export const useFormStore = create<FormState>((set) => ({
  name:     "",
  email:    "",
  setName:  (name)  => set({ name }),
  setEmail: (email) => set({ email }),
}));

create の型引数に state + action を含む型を渡します。action は set を呼ぶ関数として store 内に定義します。

selector で購読

コンポーネントは必要なスライスだけ selector で購読します。selector を絞ることで、無関係な state 変化による再レンダリングを防ぎます。

// src/components/NameDisplay.tsx
import { useFormStore } from "@/store/formStore";

export function NameDisplay() {
  const name = useFormStore((s) => s.name);
  return <p>{name}</p>;
}

set / get の使い方

// set: 部分更新(マージ)
set({ name: "new name" });

// set with function: 現在の state を参照して更新
set((state) => ({ count: state.count + 1 }));

// get: store の現在値を読む(コールバック外で使う)
create<State>((set, get) => ({
  double: () => set({ value: get().value * 2 }),
}));

Phase 1 完了条件: store の値をコンポーネントから読み書きでき、selector で再レンダリングが必要な範囲に絞られている。


Phase 2: React Hook Form + Zodでフォーム入力を確定

ゴール: Zod スキーマでバリデーションし、onSubmit で確定値を取得できるようにする。

Zod スキーマ定義

// src/schemas/formSchema.ts
import { z } from "zod";

export const formSchema = z.object({
  name:  z.string().min(1, "名前を入力してください"),
  email: z.string().email("有効なメールアドレスを入力してください"),
});

export type FormValues = z.infer<typeof formSchema>;

z.infer<> でスキーマから TypeScript 型を導出します。これにより Zod スキーマが source of truth になります。

React Hook Form に zodResolver を接続

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

export function UserForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
  });

  const onSubmit = (data: FormValues) => {
    // data はバリデーション通過済みの確定値
    console.log(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>}
      <button type="submit">保存</button>
    </form>
  );
}

RHF は「入力中の状態管理とバリデーション実行」を担当します。Zustand は「確定後の値の保持」を担当します。この分業が Phase 3 のポイントです。

Phase 2 完了条件: フォームに不正値を入れるとエラーが出る。有効な値を送信すると onSubmit で型付きの確定値が取得できる。


Phase 3: 表示用storeへ投影

ゴール: onSubmit の確定値を Zustand store に保存し、別コンポーネントで表示できるようにする。

formStore に保存する action を追加

// src/store/formStore.ts
type FormState = {
  name:     string;
  email:    string;
  submit:   (values: FormValues) => void;
};

export const useFormStore = create<FormState>((set) => ({
  name:    "",
  email:   "",
  submit:  (values) => set(values),
}));

onSubmit で store を更新

const submit = useFormStore((s) => s.submit);

const onSubmit = (data: FormValues) => {
  submit(data);
};

表示用 store を分ける

フォーム store(入力から来た値)と表示用 store(加工・投影された値)を分けます。関心事を分離することで、フォームを複数持ったり入力中プレビューを表示したりしても、表示ロジックがフォームの変更に引きずられません。

// src/store/previewStore.ts
type PreviewState = {
  displayName: string;
  updateFrom:  (name: string) => void;
};

export const usePreviewStore = create<PreviewState>((set) => ({
  displayName: "",
  updateFrom:  (name) => set({ displayName: name.trim().toUpperCase() }),
}));

Phase 3 完了条件: フォームを送信すると formStore が更新され、別コンポーネントで値が表示される。formStore と previewStore が別々に定義されている。


Phase 4: subscribeでstore間同期

ゴール: formStore の変化を previewStore に subscribe で自動伝播させる。

subscribe の基本

// src/store/sync.ts
import { useFormStore } from "./formStore";
import { usePreviewStore } from "./previewStore";

export function setupSync() {
  const unsubscribe = useFormStore.subscribe((state) => {
    usePreviewStore.getState().updateFrom(state.name);
  });

  return unsubscribe;
}

subscribe は store の 任意の変化 を検知します。コールバックは次の state(と前の state)を受け取ります。

subscribeWithSelector で特定フィールドだけ監視

全フィールドの変化に反応するのではなく、name の変化だけを監視したい場合は subscribeWithSelector middleware を使います。

import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";

export const useFormStore = create<FormState>()(
  subscribeWithSelector((set) => ({
    name:   "",
    email:  "",
    submit: (values) => set(values),
  }))
);

// name が変わったときだけ発火
const unsubscribe = useFormStore.subscribe(
  (s) => s.name,
  (name) => {
    usePreviewStore.getState().updateFrom(name);
  }
);

getState()subscribe() の違い

getState()subscribe()
タイミング呼んだ瞬間の値を一回読む変化が起きるたびに通知
使い所action 内・コールバック外での参照store 間同期・外部連携
コンポーネント内使わない(selector を使う)使わない(useEffect で管理)
// action 内で別 store の現在値を参照する
const submit = (values: FormValues) => {
  const { mode } = useConfigStore.getState(); // 一回読み
  if (mode === "strict") validateStrictly(values);
  set(values);
};

unsubscribe の管理

subscribe は解除しないとメモリリークになります。useEffect の cleanup で必ず解除します。

useEffect(() => {
  const unsubscribe = setupSync();
  return () => unsubscribe();
}, []);

アプリ全体で一度だけ設定する場合は、ルートコンポーネントの useEffect か、専用の初期化関数で管理します。

Phase 4 完了条件: formStore が更新されると previewStore が自動で追随する。アンマウント時に unsubscribe が呼ばれる。


Phase 5: Debugと外部Demo公開

ゴール: devtools で状態変化を可視化し、Vercel に外部デモとして公開する。

devtools middleware

import { create } from "zustand";
import { devtools } from "zustand/middleware";

export const useFormStore = create<FormState>()(
  devtools(
    subscribeWithSelector((set) => ({
      name:   "",
      email:  "",
      submit: (values) => set(values, false, "submit"),
    })),
    { name: "FormStore" }
  )
);

set の第 3 引数にアクション名を渡すと Redux DevTools に表示されます。devtoolsname オプションで store 名を識別できます。

Redux DevTools Extension での確認

  1. Chrome 拡張 「Redux DevTools」をインストール
  2. アプリを起動して DevTools パネルを開く
  3. フォームを送信すると submit アクションが記録される
  4. 任意の時点の state にタイムトラベルできる

UI 切り替えコンポーネント

複数の store 挙動をタブ切り替えで比較できる構成にします。

const tabs = ["フォーム", "詳細", "プレビュー", "デバッグ"] as const;

export function DemoLayout() {
  const [active, setActive] = useState<typeof tabs[number]>("フォーム");
  return (
    <div>
      <nav>{tabs.map((t) => <button key={t} onClick={() => setActive(t)}>{t}</button>)}</nav>
      {active === "フォーム"   && <FormView />}
      {active === "詳細"       && <DetailView />}
      {active === "プレビュー" && <PreviewView />}
      {active === "デバッグ"   && <DebugPanel />}
    </div>
  );
}

外部デモとして公開

Vercel にデプロイし、TOOLS BOX の Build に externalDemoUrl として設定します。

# content/builds/zustand-playground.mdx
externalDemoUrl: "https://zustand-test-gamma.vercel.app/zustand-demo"

Build の /builds/[slug] ページが externalDemoUrl を読み、「外部デモを見る」ボタンを表示します。

Phase 5 完了条件: DevTools で store の変化を追える。/builds/zustand-playground から外部デモへ遷移できる。


設計判断まとめ

RHF と Zustand の責務を分けた理由。フォームは「入力中の一時状態管理とバリデーション実行」が役割です。Zustand は「確定後の値を全コンポーネントで共有」するための場所です。RHF に確定値を保持させると、フォームのリセットや条件分岐で状態が漏れやすくなります。役割を分けることで、フォームを再レンダリングしても store の値は影響を受けません。

Zod で store 値を安定させる理由。バリデーションを通過した値だけが store に書き込まれるため、store の型と実際の値が一致することが保証されます。z.infer<> で TypeScript 型を導出しているため、スキーマが source of truth になり、型定義の二重管理が不要です。

表示用 store を分けた理由。formStore は入力元の値を持ち、previewStore は加工・投影された値を持ちます。フォームに変更を加えても表示ロジックが壊れません。複数のフォームから一つの表示 store に集約したり、フォームなしで表示を更新したりする拡張が自然にできます。

getState()subscribe() の違いgetState() は呼んだ瞬間の値を一回読む操作です。subscribe() は変化が起きるたびに通知を受け取る購読です。action 内で別 store の値を参照する場合は getState()、store 間の自動同期には subscribe() を使います。コンポーネント内では selector(React binding)を使い、どちらも直接呼びません。

unsubscribe を明示する理由subscribe はコールバックを外部に登録するため、解除しないとコンポーネントがアンマウントされてもコールバックが残りメモリリークになります。useEffect の cleanup 関数で必ず解除することで、ライフサイクルに合わせた安全な購読管理ができます。

TOOLS BOX 本体へ直接組み込まない理由zustand_test は MUI・Next.js・React のバージョン構成が TOOLS BOX 本体と異なります。依存関係を本体に持ち込むと UI 方針やバージョン管理が重くなります。外部 Playground として分離することで、本体を安定した教材サイトに保ちつつ、Playground 側を自由に壊して試せる実験環境として維持できます。


次に足せるもの

  • immer middleware — ネストした state をミュータブルに更新できるようにする
  • persist middleware — localStorage に state を永続化する
  • 複数 slice の結合combine で store を slice 構成に分割する
  • Unit testuseFormStore の action と selector を Vitest でテストする
  • Playbook 公開 — repo を public にして GitHub リンクを有効にする
  • TOOLS BOX 本体移植 — MUI を使わず Tailwind UI として /playgrounds/zustand-demo を実装する