概要
Zustand Test の右サイドには、4 つの store の値を 常時表示する Store Monitor があります。 これは Redux DevTools とは別に、画面の中にいつでも見える形で置いておくと、フォーム入力 → store 投影 → 同期、という流れを体感しやすいからです。
設計方針は3つです。
- Monitor 専用の selector を store ごとに用意し、表示領域だけを購読する
- オブジェクトを返す selector は
useShallowで包んで再レンダリングを抑える - Monitor の更新と本体 UI の更新を分離 する。Monitor 由来の更新が本体に波及しないようにする
インストール
npm install zustand
1 つの store を表示する MonitorSection
最小単位は「1 つの store の中身を <pre> で出すコンポーネント」です。useShallow でラップしておけば、関係のないフィールドの変化では再レンダリングされません。
// src/components/monitor/SettingsMonitor.tsx
"use client";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/store/settingsStore";
export function SettingsMonitor() {
const snapshot = useSettingsStore(
useShallow((s) => ({
name: s.name,
email: s.email,
rate: s.rate,
}))
);
return (
<section>
<h3>settingsStore</h3>
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
</section>
);
}
action はあえて含めません。Monitor は 値を見たいだけ であり、関数の参照差分まで監視する必要がないためです。
4 つの store を並べる Monitor 本体
各 store について同じ形の Monitor を作り、サイドパネルとして並べます。
// src/components/monitor/StoreMonitor.tsx
"use client";
import { SettingsMonitor } from "./SettingsMonitor";
import { ProfileMonitor } from "./ProfileMonitor";
import { DetailMonitor } from "./DetailMonitor";
import { UiMonitor } from "./UiMonitor";
export function StoreMonitor() {
return (
<aside className="space-y-3 p-3 text-xs">
<SettingsMonitor />
<ProfileMonitor />
<DetailMonitor />
<UiMonitor />
</aside>
);
}
StoreMonitor 自体は 何も購読しません。各 Monitor が自分の store だけを購読しているため、SettingsMonitor の再レンダリングは ProfileMonitor に波及しません。
レイアウトに置く
タブエリアの右に Monitor を常時並べます。タブを切り替えてもサイドパネルは消えません。
// src/app/zustand-demo/page.tsx
import { TabbedArea } from "@/components/TabbedArea";
import { StoreMonitor } from "@/components/monitor/StoreMonitor";
export default function ZustandDemoPage() {
return (
<div className="grid grid-cols-[1fr_280px] gap-4">
<TabbedArea />
<StoreMonitor />
</div>
);
}
タブが切り替わっても StoreMonitor は再マウントされないので、購読が張り直される無駄が起きません。
なぜ useStore() 全部購読を避けるか
useSettingsStore() を引数なしで呼ぶと state 全体を購読してしまい、1 文字入力するたびに Monitor が再レンダリング されます。watch 経由でリアルタイム同期している Playground ではこれが特に致命的です。
selector で値を絞ると、変化したフィールドのみが Monitor の再描画を引き起こします。rate だけ変わったときに name の <pre> まで再描画されることを避けられます。
// ❌ 避ける
const all = useSettingsStore();
// ✅ 推奨
const snapshot = useSettingsStore(
useShallow((s) => ({ name: s.name, email: s.email, rate: s.rate }))
);
getState() ではなく selector を使う理由
「ただ値を読むだけなら useSettingsStore.getState().name でもいいのでは?」という疑問が出ますが、それでは store の変化を Monitor が拾えません。getState() は呼んだ瞬間の値を一回返すだけで、Zustand から再レンダリング通知が来ません。
Monitor は 変化のたびに再描画したい コンポーネントなので、必ず useStore(selector) で購読します。
派生状態を Monitor で見るときは個別 selector
detailStore.suggestedRate のような 派生値 も同じ要領で表示できます。派生の作り方は 派生状態編 を参照してください。
// src/components/monitor/DetailMonitor.tsx
"use client";
import { useShallow } from "zustand/react/shallow";
import { useDetailStore } from "@/store/detailStore";
export function DetailMonitor() {
const snapshot = useDetailStore(
useShallow((s) => ({
mode: s.mode,
suggestedRate: s.suggestedRate,
}))
);
return (
<section>
<h3>detailStore</h3>
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
</section>
);
}
suggestedRate が変わるのは settingsStore.rate の変化に追随する瞬間です。Monitor 上でそのタイミングが目に見えるため、storeSync の挙動を理解する助けになります。
ポイント
- Monitor は store ごとに 1 コンポーネント に分け、
useShallowで必要なフィールドだけ購読する - 親(
StoreMonitor)は何も購読せず、子の再レンダリングが互いに波及しない構造にする useStore()全部購読は避ける。1 文字入力ごとに Monitor 全体が再描画される- 値を「読むだけ」なら
getState()でも取れるが、変化を追えないため Monitor では使わない - 派生 store も同じ要領で表示する。同期タイミングが目に見えるため、
storeSyncのデバッグが楽になる
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 監視対象の 4 store と storeSync の設計
- 派生状態編 — Monitor に映る派生値の作り方
- selector 最適化 —
useShallowと primitive selector の使い分け - Zustand Test (Build) — 完成例と外部デモ