この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にあります。
GitHub リポジトリは現在準備中(private)です。動作確認は外部デモから行えます。
5 つのフェーズに分けて、この Playground を段階的に構築します。
| フェーズ | ゴール |
|---|---|
| Phase 1 | Zustand store の最小構成が動く |
| Phase 2 | React Hook Form + Zod でフォーム入力を確定できる |
| Phase 3 | 確定値を表示用 store へ投影できる |
| Phase 4 | subscribe で store 間を同期できる |
| Phase 5 | devtools でデバッグし、外部デモとして公開する |
データの流れ
フォーム入力
↓
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 に表示されます。devtools の name オプションで store 名を識別できます。
Redux DevTools Extension での確認
- Chrome 拡張 「Redux DevTools」をインストール
- アプリを起動して DevTools パネルを開く
- フォームを送信すると
submitアクションが記録される - 任意の時点の 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 側を自由に壊して試せる実験環境として維持できます。
次に足せるもの
immermiddleware — ネストした state をミュータブルに更新できるようにするpersistmiddleware — localStorage に state を永続化する- 複数 slice の結合 —
combineで store を slice 構成に分割する - Unit test —
useFormStoreの action と selector を Vitest でテストする - Playbook 公開 — repo を public にして GitHub リンクを有効にする
- TOOLS BOX 本体移植 — MUI を使わず Tailwind UI として
/playgrounds/zustand-demoを実装する