概要
Zustand のアクションは create の中で 1 度だけ作られ、store の寿命が続く限り参照が変わりません。つまり「useStore((s) => s.someAction) で取り出した関数は、props で渡しても再レンダリングを誘発しない」が成り立ちます。
Zustand Test ではこの性質を踏まえ、アクションは selector で取り出して props で子に渡す 設計を採っています。子は「props として渡された関数を呼ぶだけ」になり、store の存在を知らずに済みます。
インストール
npm install zustand
親で取り出して子に渡す基本形
「保存ボタン」だけを子コンポーネントに分けても、親が再レンダリングする回数は増えません。アクションの参照は不変だからです。
// 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 (
<form>
{/* ...入力欄... */}
<SaveButton onSave={setFormData} />
</form>
);
}
// src/components/SaveButton.tsx
"use client";
import type { FormData } from "@/schemas/formSchema";
type Props = {
onSave: (values: FormData) => void;
};
export function SaveButton({ onSave }: Props) {
return <button type="submit">保存</button>;
}
子コンポーネントは Zustand を import しません。「onSave という関数を持つだけ」 に閉じるため、テストで onSave={vi.fn()} を渡すのが容易になります。
なぜ参照が不変か
Zustand の create は次のような形で state と action を 1 度だけ作ります。
export const useSettingsStore = create<SettingsState>((set) => ({
/* state */
patchFormData: (partial) => set(partial),
setFormData: (values) => set(values),
reset: () => set(INITIAL_SETTINGS),
}));
patchFormData などの関数オブジェクトは このコールバックが 1 度実行された時 にだけ生成されます。それ以降は同じ参照が s.patchFormData から返り続けるため、selector の戻り値は変わりません。
state と action は別 selector に分ける
state とアクションを 1 つのオブジェクトで返す書き方は、useShallow を忘れると毎レンダリングで再描画が走ります。アクションは 個別の selector で取り出す方が事故が少ないです。
// ❌ shallow なしでオブジェクトを返すと毎回別参照
const { name, setFormData } = useSettingsStore((s) => ({
name: s.name,
setFormData: s.setFormData,
}));
// ✅ 個別の selector
const name = useSettingsStore((s) => s.name);
const setFormData = useSettingsStore((s) => s.setFormData);
selector を分けると、name の変化はその行を含むコンポーネントだけに局所化されます。
useEffect の依存配列にアクションを入れて良い
「アクションが依存配列に入っていると毎回走るのでは?」と心配になりますが、参照が不変なので 依存配列に入れて構いません。むしろ ESLint の react-hooks/exhaustive-deps ルールが満たされるため、書いておくのが推奨です。
"use client";
import { useEffect } from "react";
import { useSettingsStore } from "@/store/settingsStore";
import { useFormContext } from "react-hook-form";
import type { FormData } from "@/schemas/formSchema";
export function FormWatcher() {
const patchFormData = useSettingsStore((s) => s.patchFormData);
const { watch } = useFormContext<FormData>();
useEffect(() => {
const sub = watch((value) => {
patchFormData({
name: value.name ?? "",
email: value.email ?? "",
rate: Math.min(100, Math.max(0, Number(value.rate) || 0)),
});
});
return () => sub.unsubscribe();
}, [watch, patchFormData]); // ← どちらも安定している
return null;
}
watch も patchFormData も参照が安定しているため、この useEffect は マウント時に 1 回だけ走り、unmount 時に cleanup されます。
子で store を直接参照したくなったら
「子から store を直接読みに行きたくなった」ら、その時点で 設計の見直しサイン です。基準は次のとおり。
| 状況 | 推奨 |
|---|---|
| 子は値を表示したいだけ | 親で selector して props で渡す |
| 子だけが見る重い派生 / 複数の selector | 子で useStore を直接呼ぶ |
| 子は値を書き戻したいだけ | アクションを props で渡す |
| 子が複数 store を組み合わせる | 子で個別に useStore を呼ぶ |
「props で 5 個以上の値を渡している」「同じ値を多段でバケツリレーしている」 場合は、子で useStore を直接呼ぶ方が読みやすくなります。
React の外で呼ぶときは getState()
イベントハンドラの外(例: setTimeout の中・購読のリスナー内)でアクションを呼びたいケースは、useStore.getState().action(...) を使います。selector ではないので再レンダリングを起こしません。
useSettingsStore.subscribe(
(s) => s.rate,
(rate) => {
useDetailStore.getState().setSuggestedRate(Math.max(0, rate * 1.1));
}
);
これは storeSync で多用するパターンです。詳しくは 派生状態編 を参照してください。
ポイント
- Zustand のアクションは store 生成時から 参照が不変。props で渡しても再レンダリングを誘発しない
- state とアクションは 個別の selector で取り出す。同居させるなら
useShallowを必ず付ける - アクションを
useEffectの依存配列に入れて良い。ESLint の警告も消え、意図が明確になる - 子で複数の値や派生が必要になったら、props バケツリレーをやめて子で
useStoreを直接呼ぶ - React の外で呼ぶときは
useStore.getState().action(...)。storeSyncのリスナーや非同期コールバックで使う
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — アクションが定義される 4 store の全体像
- RHF 編 —
patchFormDataをuseEffect依存に入れる実例 - uiStore 編 —
setTabを子 TabBar に渡すパターン - Zustand Test (Build) — 完成例と外部デモ