第175章:Zustand の「セレクター」
この章は「アプリが大きくなってもサクサク動かすためのコツ」だよ〜😊 Zustand の セレクター(selector) を使うと、必要なデータが変わったときだけコンポーネントが再レンダリングされるようになるの!⚡
今日のゴール 🎯
- セレクターが「なにをしてるのか」をつかむ👀
- ダメな購読(全部読む) と いい購読(必要な分だけ読む) を比較できる👌
- 複数の値をまとめて取りたいときに
useShallowを使えるようになる🧠✨(推奨されがち!) (zustand.docs.pmnd.rs)
セレクターってなに?🤔
Zustand は「ストア(状態の倉庫)」の中身を、コンポーネントが読めるんだけど…
- ✅ セレクターあり:
useStore((s) => s.count)みたいに 必要なものだけ選んで読む - ❌ セレクターなし:
useStore()で ストア全部を読む(=関係ない更新でも巻き込まれやすい)
セレクターの正体はただの関数: 「ストアの state をもらって、欲しい形に切り出して返す」だけだよ〜✂️✨
図でイメージするよ 🗺️
ハンズオン:わざと「ムダ再レンダリング」を起こして体験しよ 💥➡️✨
1) ストアを用意(すでにあるなら読み替えOK)🧩
src/store/useCounterStore.ts
import { create } from 'zustand'
type CounterState = {
count: number
name: string
inc: () => void
dec: () => void
setName: (name: string) => void
}
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
name: 'Hanako',
inc: () => set((s) => ({ count: s.count + 1 })),
dec: () => set((s) => ({ count: s.count - 1 })),
setName: (name) => set({ name }),
}))
2) 「Bad」コンポーネント(ストア全部を読む)😵
src/components/BadCounterPanel.tsx
import { useCounterStore } from '../store/useCounterStore'
export function BadCounterPanel() {
console.log('😵 BadCounterPanel render')
const store = useCounterStore() // ← ストア全部を購読(これが原因!)
return (
<section style={{ padding: 12, border: '1px solid #ccc', borderRadius: 8 }}>
<h2>😵 Bad(全部読む)</h2>
<p>count: {store.count}</p>
<button onClick={store.inc}>+1</button>{' '}
<button onClick={store.dec}>-1</button>
</section>
)
}
3) 「name を変えるだけ」のコンポーネント ✍️
src/components/NameEditor.tsx
import { useCounterStore } from '../store/useCounterStore'
export function NameEditor() {
console.log('✍️ NameEditor render')
const name = useCounterStore((s) => s.name)
const setName = useCounterStore((s) => s.setName)
return (
<section style={{ padding: 12, border: '1px solid #ccc', borderRadius: 8 }}>
<h2>✍️ 名前編集</h2>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="type your name..."
/>
<p>今の名前: {name}</p>
</section>
)
}
4) 画面に並べる 🧱
src/App.tsx
import { BadCounterPanel } from './components/BadCounterPanel'
import { NameEditor } from './components/NameEditor'
export default function App() {
return (
<main style={{ display: 'grid', gap: 12, padding: 16 }}>
<h1>Chapter 175 🐻 Selector</h1>
<NameEditor />
<BadCounterPanel />
</main>
)
}
✅ ここで input に文字を打つと…
count を触ってないのに BadCounterPanel が render されるはず!😵💦
(コンソール見てね👀)
改善:セレクターで「必要なものだけ」読む 😊✨
5) 「Good」コンポーネント(必要分だけ購読)✅
src/components/GoodCounterPanel.tsx
import { useCounterStore } from '../store/useCounterStore'
export function GoodCounterPanel() {
console.log('😊 GoodCounterPanel render')
// ✅ 必要なものだけ取る
const count = useCounterStore((s) => s.count)
const inc = useCounterStore((s) => s.inc)
const dec = useCounterStore((s) => s.dec)
return (
<section style={{ padding: 12, border: '1px solid #ccc', borderRadius: 8 }}>
<h2>😊 Good(必要分だけ読む)</h2>
<p>count: {count}</p>
<button onClick={inc}>+1</button>{' '}
<button onClick={dec}>-1</button>
</section>
)
}
App.tsx を置き換え:
import { NameEditor } from './components/NameEditor'
import { BadCounterPanel } from './components/BadCounterPanel'
import { GoodCounterPanel } from './components/GoodCounterPanel'
export default function App() {
return (
<main style={{ display: 'grid', gap: 12, padding: 16 }}>
<h1>Chapter 175 🐻 Selector</h1>
<NameEditor />
<BadCounterPanel />
<GoodCounterPanel />
</main>
)
}
✅ これで、名前を打っても GoodCounterPanel は render されにくくなるよ〜🎉 (count が変わったときだけ動く👍)
ちょい落とし穴:複数の値を「オブジェクトでまとめて返す」と…😱
こう書きたくなるんだけど👇
// ❌ これ、毎回 { ... } が新しく作られるから「違う」と判定されやすい
const { count, inc } = useCounterStore((s) => ({ count: s.count, inc: s.inc }))
オブジェクトは毎回新規になるから、実質同じでも再レンダリングが増えることがあるよ😵
解決:useShallow を使う(めっちゃ便利)🧡
Zustand の公式ドキュメントにもある定番テクだよ〜!
useShallow は「浅い比較(shallow)」で同じなら同じ扱いにしてくれるやつ✨ (zustand.docs.pmnd.rs)
import { useShallow } from 'zustand/react/shallow'
import { useCounterStore } from '../store/useCounterStore'
export function GoodCounterPanel() {
console.log('😊 GoodCounterPanel render')
// ✅ オブジェクトでまとめたいなら useShallow で包む!
const { count, inc, dec } = useCounterStore(
useShallow((s) => ({
count: s.count,
inc: s.inc,
dec: s.dec,
})),
)
return (
<section style={{ padding: 12, border: '1px solid #ccc', borderRadius: 8 }}>
<h2>😊 Good(useShallow でまとめ取り)</h2>
<p>count: {count}</p>
<button onClick={inc}>+1</button>{' '}
<button onClick={dec}>-1</button>
</section>
)
}
※ shallow(比較関数)もあるけど、useShallow は「セレクター側をいい感じにメモ化」してくれる版、みたいなイメージでOKだよ〜🫶 (zustand.docs.pmnd.rs)
どうして速くなるの?(超ざっくり)⚡
開発中の注意(コンソールが2回出る問題)👀
Vite のテンプレだと <React.StrictMode> が有効で、開発中だけ console.log が2回出ることがあるよ〜!
「壊れた!😱」じゃなくて「開発中あるある」なので安心してね🙂✨
練習問題 🎮💪(5〜10分)
-
ストアに
theme: 'light' | 'dark'を追加して、切り替えボタンを作ってみよう🌙☀️- Bad は theme 変更でも count パネルが動きがち
- Good は theme を読んでないなら動かない…はず!🎉
-
const summary = useCounterStore((s) => `\${s.name}:\${s.count}`)を作って表示してみよう🧾- これも selector だよ!✨(返り値が文字列=比較しやすい)
-
nameとcountを まとめて取りたいならuseShallowで包む練習🧡
この章のまとめ 📌✨
- セレクターは「必要なものだけ選んで読む」ための関数✂️
useStore()で全部読むと、関係ない更新でも巻き込まれやすい😵- 複数値をまとめるなら
useShallowが便利🧡(公式にも載ってる) (zustand.docs.pmnd.rs) - もっと細かい制御には equality function を使う手もある(発展) (zustand.docs.pmnd.rs)
次の章(第176章)は、これを使って Zustand カウンターを爆速で仕上げる練習だね!🐻💨✨