概要
Zustand Test の detailStore には mode と並んで suggestedRate という派生値があります。
これは settingsStore.rate から Math.max(0, rate * 1.1) のように計算した「推奨レート」で、フォーム入力に追随して動きます。
派生状態の作り方は2種類あります。
| 方法 | どこで計算するか | 向いているケース |
|---|---|---|
| selector の中で導出 | コンポーネントの useStore 内 | 単一コンポーネントだけで使う・軽い計算 |
| 別 store に書き出す | storeSync の subscribe 内 | 複数コンポーネントで参照・Monitor に映す |
Playground の suggestedRate は Monitor からも詳細タブからも参照する ため、後者を選んでいます。本記事は後者の作り方が主題です。
インストール
npm install zustand
detailStore に派生値の置き場を作る
派生値は 「同期されて入ってくる場所」 として store に持たせます。直接 set しない方針で、setSuggestedRate という専用 action を介します。
// src/store/detailStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
const INITIAL_DETAIL = {
mode: "draft" as const,
suggestedRate: 0,
};
type Mode = "draft" | "review" | "fixed";
type DetailState = {
mode: Mode;
suggestedRate: number;
setMode: (mode: Mode) => void;
setSuggestedRate: (rate: number) => void;
reset: () => void;
};
export const useDetailStore = create<DetailState>()(
devtools(
(set) => ({
...INITIAL_DETAIL,
setMode: (mode) => set({ mode }, false, "setMode"),
setSuggestedRate: (rate) => set({ suggestedRate: rate }, false, "setSuggestedRate"),
reset: () => set(INITIAL_DETAIL, true, "reset"),
}),
{ name: "DetailStore" }
)
);
派生値専用の setSuggestedRate を切ることで、「外から手で設定された値」と「同期で書かれた値」が混ざらない 設計を保てます。直接 set を許してしまうと、後で Monitor を見て「これは誰が書いた?」が追えなくなります。
storeSync で settingsStore の rate を購読する
subscribeWithSelector middleware が必要なのは購読する settings 側 です。subscribe の第 1 引数に「rate だけを返す selector」、第 2 引数に「変化が起きたときに呼ぶリスナー」を渡します。
// src/store/storeSync.ts
import { useSettingsStore } from "./settingsStore";
import { useDetailStore } from "./detailStore";
export function setupSuggestedRateSync() {
const unsubscribe = useSettingsStore.subscribe(
(s) => s.rate, // selector
(rate) => { // listener
const next = Math.max(0, rate * 1.1);
useDetailStore.getState().setSuggestedRate(next);
}
);
return unsubscribe;
}
ポイントは2つです。
- selector が返す 値が変化したときだけ リスナーが発火する。
nameやemailの変化では呼ばれない - リスナーから他の store の action を呼ぶときは
useDetailStore.getState().setSuggestedRate(...)。React の外なので selector は使えない
settings 側の宣言は次のとおりです(再掲)。subscribeWithSelector を被せておくのが前提条件です。
import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
export const useSettingsStore = create<SettingsState>()(
subscribeWithSelector((set) => ({
/* state + actions */
}))
);
オブジェクトを返す selector では shallow を必ず指定する
rate は number なので参照等価で比較できますが、複数フィールドをまとめて派生に流したい場合は オブジェクトを返す selector になります。デフォルトの比較は === なので、毎回新しいオブジェクトが作られて 毎変化で発火 してしまいます。{ equalityFn: shallow } を指定すると 1 階層だけ深く比較され、内容が同じなら発火しません。
import { shallow } from "zustand/shallow";
useSettingsStore.subscribe(
(s) => ({ name: s.name, email: s.email }),
({ name, email }) => {
useProfileStore.getState().updateFrom({ name, email });
},
{ equalityFn: shallow }
);
subscribeWithSelector を使うときは 「primitive を返す → 比較指定なし」「オブジェクトを返す → shallow 必須」 とセットで覚えるとミスがありません。
起動側で setupSync を 1 度だけ呼ぶ
useEffect の初回マウントで購読を張り、cleanup で必ず解除します。アプリ全体で 1 度だけ走らせる初期化なので、ルート直下のクライアントコンポーネントに置くのが自然です。
// src/components/SyncBoot.tsx
"use client";
import { useEffect } from "react";
import { setupSuggestedRateSync } from "@/store/storeSync";
export function SyncBoot() {
useEffect(() => {
const unsubscribe = setupSuggestedRateSync();
return () => unsubscribe();
}, []);
return null;
}
複数の同期がある場合は setupSync 関数の中でまとめて配線し、戻り値の cleanup を 1 つに束ねます(Zustand 編 の setupStoreSync 参照)。
いつ selector 内で導出するか
別 store に書き出さず、selector の中で導出する 方が良いケースもあります。
// 1 か所でしか使わない・計算が軽い派生
const suggestedRate = useSettingsStore((s) => Math.max(0, s.rate * 1.1));
判断基準はシンプルです。
- 1 コンポーネントでしか使わず、計算が軽い → selector で導出
- 複数コンポーネントで参照する / Monitor に映す / 履歴を残したい → 別 store に書き出す
Playground の suggestedRate は Monitor と詳細タブの両方で見るため、別 store に書き出す方を採用しています。
ポイント
- 派生値は 専用 action 経由でのみ書き換える。直接
setを許さないと由来が追える - 購読側 store には
subscribeWithSelectormiddleware を被せる。これでsubscribe(selector, listener, options)の 3 引数形式が解放される - selector が primitive を返す なら比較指定なしで OK。オブジェクトを返す なら
{ equalityFn: shallow }を必ず指定する - 同期リスナーから別 store を更新するときは
useStore.getState().action(...)。React の外なので selector は使えない - 派生先を「別 store に書き出す」か「selector で導出」かは、参照範囲の広さ で判断する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 4 store と setupStoreSync の全体像
- Store Monitor 編 — 派生値が Monitor に映るタイミング
- selector 最適化 —
useShallowと primitive selector の使い分け - Zustand Test (Build) — 完成例と外部デモ