概要
Zustand Test では、selector の戻り値の 型(primitive か object か) に応じて、再レンダリング比較の方法を 2 通り使い分けています。
| selector の戻り値 | 比較方法 | 書き方 |
|---|---|---|
| primitive(string / number) | ===(参照等価) | そのまま useStore((s) => s.name) |
| object / array | shallow(1階層) | useStore(useShallow((s) => ({ ... }))) |
| 複数の値が欲しい | 個別 selector | const a = useStore(...); const b = ...; |
選択を間違えると、入力 1 文字ごとに無関係なコンポーネントが再描画 されたり、逆に意図したタイミングで更新されなかったりします。本記事ではそれぞれの判断基準を整理します。
インストール
npm install zustand
primitive selector はそのままで OK
返す値が string / number / boolean なら、Zustand のデフォルト比較(===)で十分です。同じ値が返るなら同じ参照なので、再レンダリングは起きません。
const name = useSettingsStore((s) => s.name);
const rate = useSettingsStore((s) => s.rate);
const setFormData = useSettingsStore((s) => s.setFormData);
action(関数)も参照が安定しているため、同様に primitive 扱い で OK です(アクション受け渡し編 参照)。
オブジェクトを返すなら useShallow を必ず付ける
複数フィールドを 1 つのオブジェクトとして取り出すと、selector は 毎回新しいオブジェクト を返します。デフォルトの === 比較では「内容が同じでも別参照」となり、関係ないフィールドが変わるたびに再レンダリングが走ります。
import { useShallow } from "zustand/react/shallow";
// ❌ shallow なし → 毎レンダリングで再描画
const { name, email } = useSettingsStore((s) => ({ name: s.name, email: s.email }));
// ✅ useShallow でラップ
const { name, email } = useSettingsStore(
useShallow((s) => ({ name: s.name, email: s.email }))
);
useShallow は オブジェクトの 1 階層を === で比較 します。ネストはたどらないので、ネストしたオブジェクトを返す selector では効きません。
ありがちな失敗例
ネストしたオブジェクトをそのまま返してしまうと、useShallow を付けても効果がありません。
// ❌ ネストを返す → useShallow でも内側を比較できない
const { profile } = useSettingsStore(
useShallow((s) => ({ profile: { name: s.name, email: s.email } }))
);
profile キーの値(オブジェクト)は 毎回別参照 になるため、shallow 比較でも「変わった」と判定されます。解決策は 2 つ。
// 1. フラットにする
const { name, email } = useSettingsStore(
useShallow((s) => ({ name: s.name, email: s.email }))
);
// 2. 個別に取る
const name = useSettingsStore((s) => s.name);
const email = useSettingsStore((s) => s.email);
「useShallow を付けたのに毎回再描画される」ときは、1 階層よりも深いオブジェクトが selector の戻り値に混ざっていないか を最初に疑います。
個別 selector とオブジェクト selector の判断
どちらが速いかは状況依存ですが、判断基準の優先順位 はおおむね次のとおりです。
- 取り出すフィールドが 1〜3 個 → 個別 selector が読みやすい
- 4 個以上をまとめて使う →
useShallowでオブジェクト selector - JSX の中で破壊代入したい →
useShallowで破壊代入の形 を選ぶ
// 個別の方が読みやすい例
const name = useSettingsStore((s) => s.name);
const setFormData = useSettingsStore((s) => s.setFormData);
// オブジェクトの方が読みやすい例(破壊代入で並べたい)
const { name, email, rate, setFormData } = useSettingsStore(
useShallow((s) => ({
name: s.name,
email: s.email,
rate: s.rate,
setFormData: s.setFormData,
}))
);
「個別 selector を 5 行並べると読みづらい」 と感じたら、オブジェクト selector + useShallow に切り替えると整理されます。
派生値を計算する selector
selector の中で計算してから返す場合、戻り値が primitive ならそのままで OK です。重い計算でなければ 毎レンダリングで再計算しても問題ありません。
// 計算しても primitive なら useShallow 不要
const suggested = useSettingsStore((s) => Math.max(0, s.rate * 1.1));
計算がオブジェクト を返す場合は、useShallow を使うか、別 store に書き出す (派生状態編) を検討します。
// ❌ 毎回新しいオブジェクト
const range = useSettingsStore((s) => ({ min: 0, max: s.rate * 1.1 }));
// ✅ useShallow で内容比較
const range = useSettingsStore(
useShallow((s) => ({ min: 0, max: s.rate * 1.1 }))
);
Store Monitor での実例
Store Monitor 編 では、useShallow を 必ず 使っています。Monitor は複数フィールドを <pre> でまとめて表示するため、自然とオブジェクト selector になるためです。
const snapshot = useSettingsStore(
useShallow((s) => ({
name: s.name,
email: s.email,
rate: s.rate,
}))
);
patchFormData が 1 文字ごとに走る Playground では、ここを useShallow なしで書くと 入力のたびに Monitor の <pre> が再描画されまくる ことになります。
ポイント
- selector の戻り値が primitive ならデフォルトの
===比較で OK。useShallowは不要 - オブジェクトを返すなら
useShallowを必ず付ける。1 階層しか比較しない点に注意 - ネストしたオブジェクトを返すと
useShallowでも防げない。フラットにするか個別 selector に分ける - 取り出すフィールドが 1〜3 個 なら個別 selector、4 個以上 なら
useShallow+ オブジェクトが読みやすい - selector の中の計算はそれ自体は軽い。戻り値の型 で
useShallow要否を判断する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — selector で再描画範囲を絞る基本
- Store Monitor 編 —
useShallowを多用する実例 - アクション受け渡し編 — アクション参照の安定性と個別 selector
- Zustand Test (Build) — 完成例と外部デモ