概要
Zustand Test は、フォーム入力をリアルタイムに反映しつつ、保存ボタンで Zod 検証を通した確定値を再書き込みする実験場です。Zustand 側は 4 つの store で役割を分担します。
| store | 役割 |
|---|---|
settingsStore | 入力値 / 確定値を保持。store 間同期の起点 |
profileStore | 表示用に整形した値を保持(settingsStore から自動同期) |
detailStore | 詳細画面用の派生状態(mode / suggestedRate など) |
uiStore | アクティブなタブを保持。表示制御のみ |
データの流れは フォーム入力 → settingsStore → storeSync → profileStore / detailStore → 各 UI の一方向です。本記事ではこのうち Zustand 部分 を上から順に解説します。
インストール
npm install zustand
settingsStore の最小構成
入力中・確定後の値を1つの store にまとめ、初期値を別オブジェクトとして外に切り出すのがポイントです。リセット時に 同じ初期値 をまた使えるようにするためです。
// src/store/settingsStore.ts
import { create } from "zustand";
import type { FormData } from "@/schemas/formSchema";
const INITIAL_SETTINGS: FormData = {
name: "",
email: "",
rate: 0,
};
type SettingsState = FormData & {
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
reset: () => void;
};
export const useSettingsStore = create<SettingsState>((set) => ({
...INITIAL_SETTINGS,
patchFormData: (partial) => set(partial),
setFormData: (values) => set(values),
reset: () => set(INITIAL_SETTINGS),
}));
patchFormData と setFormData を分けている意図は Zod 編 で詳しく扱いますが、要約すると 「Zod を通っていない部分更新」 と 「Zod を通った確定書き込み」 を区別するためです。
selector で必要なスライスだけ購読する
コンポーネントは useSettingsStore(selector) で必要なスライスだけ購読します。primitive を返すと参照等価で比較され、無関係な変化で再レンダリングされません。
// src/components/NameDisplay.tsx
"use client";
import { useSettingsStore } from "@/store/settingsStore";
export function NameDisplay() {
const name = useSettingsStore((s) => s.name);
return <p>{name}</p>;
}
action(patchFormData 等)も useSettingsStore((s) => s.patchFormData) で取り出します。action は store 生成時から参照が変わらないため、コンポーネント間でアクションを props で受け渡し ても再レンダリングは追加で発生しません。
set / get の使い分け
create のコールバックで受け取る set / get は store 内部から呼ぶ用です。
// 1. set: 部分更新(マージ)
set({ name: "new name" });
// 2. set with function: 現在の state を参照して更新
set((state) => ({ rate: state.rate + 1 }));
// 3. get: 現在値を一回読む
create<State>((set, get) => ({
doubleRate: () => set({ rate: get().rate * 2 }),
}));
コンポーネント側からは get() を直接呼びません。代わりに useSettingsStore((s) => s.rate) を使うと、再レンダリング制御が効きます。
getState() と subscribe の違い
React の外側から store にアクセスする場面で使い分けます。
getState() | subscribe() | |
|---|---|---|
| タイミング | 呼んだ瞬間の値を 一回読む | 変化が起きるたびに通知 |
| 再レンダリング | 起こさない | 起こさない(自分で UI 反映を書く) |
| 主な用途 | 別 store の現在値を参照 | store 間の自動同期、外部連携 |
| コンポーネント内 | 使わない(selector を使う) | 使わない(useEffect で管理) |
getState() は 読み切り の操作、subscribe() は 継続購読 の操作と覚えると使い分けが整理されます。
storeSync で store 間を連携する
storeSync は settingsStore の変化を起点に profileStore / detailStore を更新するレイヤです。subscribeWithSelector middleware を使い、特定フィールドの変化だけに反応させます。
// src/store/settingsStore.ts
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
export const useSettingsStore = create<SettingsState>()(
subscribeWithSelector((set) => ({
/* state + actions */
}))
);
// src/store/storeSync.ts
import { shallow } from "zustand/shallow";
import { useSettingsStore } from "./settingsStore";
import { useProfileStore } from "./profileStore";
import { useDetailStore } from "./detailStore";
export function setupStoreSync() {
// settings の name / email が変わったら profile を更新
const unsub1 = useSettingsStore.subscribe(
(s) => ({ name: s.name, email: s.email }),
({ name, email }) => {
useProfileStore.getState().updateFrom({ name, email });
},
{ equalityFn: shallow }
);
// settings の rate が変わったら detail の suggestedRate を更新
const unsub2 = useSettingsStore.subscribe(
(s) => s.rate,
(rate) => {
useDetailStore.getState().setSuggestedRate(Math.max(0, rate * 1.1));
}
);
return () => {
unsub1();
unsub2();
};
}
ポイントは3つです。
subscribeWithSelectorはsubscribe(selector, listener, options?)の 3 引数 形式を解放する- オブジェクトを返す selector は
{ equalityFn: shallow }を指定しないと毎回別参照になり発火しすぎる - 同期先の store には
getState()で action を取り出して呼ぶ(React の外なので selector は使えない)
アクションをコンポーネント間で受け渡す
action はオブジェクト ID として安定しているため、props で渡しても再レンダリングを誘発しません。これにより「保存ボタン」を子コンポーネントに分離しても、上のフォームが再描画されません。
// src/components/SettingsForm.tsx
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { SaveButton } from "./SaveButton";
export function SettingsForm() {
const setFormData = useSettingsStore((s) => s.setFormData);
// ↓ 子に渡しても参照は変わらない
return <SaveButton onSave={setFormData} />;
}
action を受け取る側は単純な関数 props として扱えます。
devtools middleware で可視化する
devtools を被せ、set の第 3 引数にアクション名を渡すと Redux DevTools のタイムラインで識別できます。store ごとに name を変えれば、複数 store を並べて追えます。
import { create } from "zustand";
import { devtools, subscribeWithSelector } from "zustand/middleware";
export const useSettingsStore = create<SettingsState>()(
devtools(
subscribeWithSelector((set) => ({
...INITIAL_SETTINGS,
patchFormData: (partial) => set(partial, false, "patchFormData"),
setFormData: (values) => set(values, false, "setFormData"),
reset: () => set(INITIAL_SETTINGS, true, "reset"),
})),
{ name: "SettingsStore" }
)
);
第 2 引数を true にすると state を置き換え(merge ではなく replace) するため、リセット時の挙動と相性が良いです。
store リセット(初期値への復元)
各 store に reset action を持たせ、画面の「全ストアをリセット」ボタンから順に呼びます。
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { useProfileStore } from "@/store/profileStore";
import { useDetailStore } from "@/store/detailStore";
import { useUiStore } from "@/store/uiStore";
export function ResetAllButton() {
const onClick = () => {
useSettingsStore.getState().reset();
useProfileStore.getState().reset();
useDetailStore.getState().reset();
useUiStore.getState().reset();
};
return <button onClick={onClick}>全ストアをリセット</button>;
}
getState().reset() を React の外から呼んでいるのは、ハンドラがマウント中の任意の瞬間に発火するためです。selector を経由する必要がない場面では getState() の方が手数が少なく済みます。
unsubscribe 管理
subscribe は登録したコールバックを Zustand 側に保持させます。解除しないとアンマウント後も呼ばれ続けるので、useEffect の cleanup で必ず解除します。
"use client";
import { useEffect } from "react";
import { setupStoreSync } from "@/store/storeSync";
export function SyncBoot() {
useEffect(() => {
const unsubscribe = setupStoreSync();
return () => unsubscribe();
}, []);
return null;
}
アプリ全体で一度だけ立ち上げる用途なので、ルートレイアウト直下に置くのが自然です。
ポイント
create<T>()に state と action を1つにまとめ、初期値は外部定数として切り出すと reset で再利用 できる- selector は primitive を返すと再描画を最小化できる。action は参照が安定しているため props で受け渡しても安全
- React 外からの操作は
getState()、変化の継続監視はsubscribe()。役割を必ず分ける - 多 store 連携は
subscribeWithSelector+shallowで無関係なフィールドでは発火させず、戻り値のunsubscribeをuseEffectの cleanup で必ず呼ぶ devtoolsのset第 3 引数にアクション名、{ name: "..." }で store 名を識別。reset はset(initial, true, "reset")で 置き換え更新
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zod 編 —
patchFormData/setFormDataの使い分けとcoerce - TypeScript 編 — 4 store を貫く型の流れ
- Zustand Test (Build) — 完成例と外部デモ