はじめに
Reactアプリが大きくなってくると、「prop drilling」という問題に直面します。コンポーネントツリーの深い場所にあるコンポーネントにstateを渡すために、3〜4層を経由してpropsをバケツリレーしていく、あの問題です。よく使われる解決策は useContext ですが、Contextにはパフォーマンス面で大きな欠点があります。stateが変わるたびに、そのstateを使っていない子コンポーネントまで含めて、すべてが再レンダリングされてしまうのです。
たとえば、サイドバー・ヘッダー・データテーブルが同じContextを参照しているダッシュボードを想像してください。ヘッダーの小さな通知バッジを更新しただけで、サイドバーもデータテーブルも一緒に再レンダリングされます。複雑なアプリでは、特にローエンドのデバイスでこれが明らかなカクつきの原因になります。
Zustandはこの2つの問題をまとめて解決します。Providerで囲む必要のないグローバルstateと、変更されたstateを実際に使っているコンポーネントだけを再レンダリングする仕組みを提供します。
npm install zustand
基本的な使い方
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
コンポーネントで使う場合:
function Counter() {
const count = useCounterStore((s) => s.count)
const increment = useCounterStore((s) => s.increment)
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
</div>
)
}
まず覚えておくべき3つのポイント:
| 説明 | |
|---|---|
create |
storeを作成する。関数を受け取り、カスタムhookを返す |
set |
stateを更新する。現在のstateにマージされる(上書きではない) |
selector |
hookに渡して、必要なstateだけを選択するための関数 |
ポイント1: セレクタで不要な再レンダリングを防ぐ
useContext との最大の違いがここです。必要なstateだけを選択することで、他のstateが変わったときにコンポーネントが再レンダリングされなくなります。
// 良くない例: store全体を取得 → 何かが変わるたびに再レンダリング
const store = useUserStore()
// 良い例: usernameが変わったときだけ再レンダリング
const username = useUserStore((s) => s.username)
複数の値を同時に取得したいとき、こう書きたくなるかもしれません:
// 注意: レンダリングのたびに新しいオブジェクトを生成するため
// Zustandがstateが常に変わったと判断してしまい、再レンダリングが続く
const { username, email } = useUserStore((s) => ({ username: s.username, email: s.email }))
問題はここにあります。セレクタが実行されるたびに、username も email も変わっていないのに新しいオブジェクトが返されます。Zustandは === で比較するため、参照が異なる2つのオブジェクトは中身が同じでも「変わった」とみなされます。useShallow を使えば、参照ではなくキーごとに比較できます:
import { useShallow } from 'zustand/react/shallow'
// useShallowはキーごとに比較するため、usernameかemailが実際に変わったときだけ再レンダリング
const { username, email } = useUserStore(
useShallow((s) => ({ username: s.username, email: s.email }))
)
ポイント2: 非同期アクションでAPIを呼び出す
set は通常の非同期関数の中でそのまま使えます。ミドルウェアや追加の設定は不要です:
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const data = await fetch(`/api/users/${id}`).then((r) => r.json())
set({ user: data, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
}))
コンポーネントで使う場合:
function UserProfile({ id }) {
const user = useUserStore((s) => s.user)
const loading = useUserStore((s) => s.loading)
const fetchUser = useUserStore((s) => s.fetchUser)
// fetchUserはstoreで定義されているため安定した参照を持つ
// 依存配列に入れてもESLintのルールに従いつつ、無限ループにもならない
useEffect(() => { fetchUser(id) }, [id, fetchUser])
if (loading) return <p>読み込み中...</p>
return <p>{user?.name}</p>
}
ポイント3: persistでlocalStorageにstateを保存する
persist ミドルウェアを使えば、ページをリロードしてもstateが消えません。テーマ、言語設定、カート、ユーザー設定などに便利です。
セキュリティに関する注意: 認証トークンをlocalStorageに保存するのは避けましょう。XSSによる盗難リスクがあります。トークンはサーバー側でhttpOnly Cookieを使って管理するのが適切です。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'ja',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorageのキー名
}
)
)
特定のフィールドだけを保存したい場合は、partialize を使います:
persist(
(set) => ({
theme: 'light',
tempData: null,
// 他のstate
}),
{
name: 'app-settings',
partialize: (state) => ({ theme: state.theme }), // themeだけ保存、tempDataは除外
}
)
ポイント4: 大規模なstoreの整理にはスライスパターン
アプリが大きくなってきたら、すべてを1つのstoreに詰め込むのはやめましょう。複数の「スライス」に分割してから合体させます:
// store/userSlice.js
export const createUserSlice = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
})
// store/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clearCart: () => set({ items: [] }),
})
// store/index.js: すべてをまとめる
const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}))
export default useStore
合体後は、すべてのスライスのstateとactionが useStore に集約されます。どのスライスに属しているかを意識せず、通常通りセレクタで使えます:
function Header() {
// userSliceのstate
const user = useStore((s) => s.user)
const logout = useStore((s) => s.logout)
// cartSliceのstate: 同じuseStore、異なるセレクタ
const itemCount = useStore((s) => s.items.length)
return (
<header>
<span>ようこそ、{user?.name}さん</span>
<span>カート: {itemCount}件</span>
<button onClick={logout}>ログアウト</button>
</header>
)
}
セレクタを別ファイルにまとめて再利用するチームも多くあります。複数のコンポーネントで同じセレクタを書き直す手間が省け、フィールド名を変更する際のリファクタリングも楽になります:
// store/selectors.js
export const selectUser = (s) => s.user
export const selectCartItems = (s) => s.items
export const selectItemCount = (s) => s.items.length
// コンポーネントで使う: すっきりしてプロジェクト全体で一貫性が保てる
const user = useStore(selectUser)
const itemCount = useStore(selectItemCount)
ポイント5: コンポーネントの外でstateを使う
Zustandはコンポーネントやhookの外でもstateの読み書きができます。axiosのインターセプター、WebSocketハンドラー、ユーティリティファイルなどで役立ちます:
// 現在のstateを読み取る
const currentUser = useUserStore.getState().user
// コンポーネントの外からstateを更新する
useUserStore.setState({ user: null })
subscribe でstateの変化を監視できます。シグネチャは2種類あります:
// 形式1: subscribe(listener) — store全体の変化を監視
// ミドルウェアなしでそのまま使える
const unsub = useUserStore.subscribe((state) => {
console.log('Store changed:', state)
})
セレクタ付きの形式2は、storeを作成するときに subscribeWithSelector ミドルウェアを追加する必要があります。追加しない場合、エラーが出ることもなくlistenerが呼ばれないので注意してください:
import { subscribeWithSelector } from 'zustand/middleware'
// ミドルウェアを追加してstoreを定義
const useUserStore = create(
subscribeWithSelector((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
)
// 形式2: subscribe(selector, listener) — 指定したstateが変化したときだけ発火
// selector: 監視したいstateを選択
// listener: (新しい値, 前の値) を受け取る
const unsub = useUserStore.subscribe(
(state) => state.user, // selector: userだけ監視
(user, prevUser) => { // listener: userが変わったときに実行
console.log('User changed from', prevUser, 'to', user)
}
)
// メモリリークを防ぐため、不要になったらsubscribeを解除する
unsub()
ポイント6: 深くネストしたstateにはImmerを使う
深くネストしたオブジェクトを更新するとき、イミュータブルなコードは非常に長くなり、途中のspreadを忘れてデータが消えるバグも発生しやすいです。immer ミドルウェアを使えば、直接「変更」するような書き方ができ、内部ではイミュータビリティが保証されます:
npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: {
profile: { name: '', avatar: '' },
address: { city: '', district: '' },
},
// 直接変更するように書くだけ — あとはImmerが処理してくれる
setCity: (city) => set((state) => {
state.user.address.city = city
}),
setName: (name) => set((state) => {
state.user.profile.name = name
}),
}))
)
Immerなしの場合との比較:
// 良くない例: 冗長で、spreadを忘れると他のフィールドのデータが消える
setCity: (city) => set((state) => ({
user: {
...state.user,
address: { ...state.user.address, city },
},
}))
まとめ
| ユースケース | 使うもの |
|---|---|
| 基本のstore |
create + set
|
| 不要な再レンダリングを防ぐ | セレクタ + useShallow
|
| storeの中でAPIを呼ぶ | 通常の非同期アクション |
| localStorageにstateを保存 |
persist ミドルウェア |
| 大規模アプリ、複数ドメイン | スライスパターン |
| コンポーネント外で使う |
getState() / setState() / subscribe()
|
| 深くネストしたオブジェクト |
immer ミドルウェア |
Zustandにはstoreの構成に「唯一の正解」はありません。シンプルに始めて、必要に応じて拡張できる柔軟さが魅力です。ただし、常にZustandが必要なわけではありません。コンポーネントが1〜2つ程度でstateを共有するだけなら、useState + propsや useContext で十分です。Zustandが真価を発揮するのは、コンポーネントツリーの中で互いに関係のない複数の場所から同じstateを参照する場合——そこが、Zustandが本当に解決してくれる問題です。
