zustand の使い方 — React状態管理をシンプルに実現するライブラリ
一言でいうと
zustandは、Reactアプリケーションのための軽量・高速・スケーラブルな状態管理ライブラリです。HooksベースのシンプルなAPIで、ReduxのようなボイラープレートやContext Providerのラップなしに、グローバルな状態管理を実現します。
どんな時に使う?
- コンポーネント間で状態を共有したい時 — 認証情報、テーマ設定、カート情報など、複数コンポーネントにまたがるグローバルステートの管理
- Reduxが重すぎると感じた時 — Action/Reducer/Dispatchの定型コードを書かずに、最小限のコードで状態管理を始めたい場合
- React Context のパフォーマンス問題を回避したい時 — Contextでは関連しない状態変更でも再レンダリングが発生しがちですが、zustandはセレクターによる細かい再レンダリング制御が可能
インストール
# npm
npm install zustand
# yarn
yarn add zustand
# pnpm
pnpm add zustand
本記事はzustand v5系(5.0.12)を対象としています。v4以前とはAPIに一部差異があります。
zustand の基本的な使い方
zustandの基本は「ストアを作り、コンポーネントでフックとして使う」の2ステップです。
1. ストアを作成する
import { create } from 'zustand'
interface BearState {
bears: number
increasePopulation: () => void
removeAllBears: () => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
create に渡す関数の第一引数 set は状態を更新する関数です。set はデフォルトでマージ動作(既存の状態に浅くマージ)します。
2. コンポーネントで使う
function BearCounter() {
// セレクターで必要な値だけ取り出す → bearsが変わった時だけ再レンダリング
const bears = useBearStore((state) => state.bears)
return <h1>{bears} bears around here</h1>
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
Providerで囲む必要は一切ありません。 これがzustandの最大の特徴の一つです。
よく使うAPI
1. create — ストアの作成
最も基本的なAPIです。状態とアクションを一つのオブジェクトにまとめて定義します。
import { create } from 'zustand'
interface TodoState {
todos: string[]
addTodo: (todo: string) => void
removeTodo: (index: number) => void
}
const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (todo) =>
set((state) => ({ todos: [...state.todos, todo] })),
removeTodo: (index) =>
set((state) => ({
todos: state.todos.filter((_, i) => i !== index),
})),
}))
2. set の第二引数(状態の置換)
set の第二引数に true を渡すと、マージではなく完全な置換になります。
interface FishState {
salmon: number
tuna: number
reset: () => void
}
const useFishStore = create<FishState>((set) => ({
salmon: 1,
tuna: 2,
// 第二引数 true で状態を完全に上書き(注意:アクションも消える)
reset: () => set({ salmon: 0, tuna: 0 } as FishState, true),
}))
3. get — アクション内で現在の状態を読み取る
create の第二引数 get を使うと、アクション内で最新の状態を同期的に取得できます。
interface SoundState {
sound: string
volume: number
playIfLoud: () => void
}
const useSoundStore = create<SoundState>((set, get) => ({
sound: 'grunt',
volume: 80,
playIfLoud: () => {
const { sound, volume } = get()
if (volume > 50) {
console.log(`Playing ${sound} at volume ${volume}`)
}
},
}))
4. useShallow — 複数の状態を浅い比較で取得する
複数の値をまとめて取得する際、不要な再レンダリングを防ぐために useShallow を使います。
import { useShallow } from 'zustand/react/shallow'
function NutritionInfo() {
// オブジェクトで取得 — nuts か honey が変わった時だけ再レンダリング
const { nuts, honey } = useBearStore(
useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)
// 配列で取得することも可能
// const [nuts, honey] = useBearStore(
// useShallow((state) => [state.nuts, state.honey]),
// )
return (
<div>
Nuts: {nuts}, Honey: {honey}
</div>
)
}
5. コンポーネント外からの状態操作(getState / setState / subscribe)
ストアのフック自体にユーティリティメソッドが付いており、React外からも状態を操作・購読できます。
// コンポーネント外で最新の状態を取得
const currentBears = useBearStore.getState().bears
// コンポーネント外で状態を更新
useBearStore.setState({ bears: 10 })
// 状態変更を購読(すべての変更で発火)
const unsubscribe = useBearStore.subscribe((state) => {
console.log('State changed:', state)
})
// 購読解除
unsubscribe()
⚠️ React Server Components(Next.js App Routerなど)では、コンポーネント外でのストア操作は予期しないバグやプライバシー問題を引き起こす可能性があります。
6. 非同期アクション
zustandは非同期アクションを特別扱いしません。set を呼ぶタイミングが非同期でも問題なく動作します。
interface FishState {
fishies: Record<string, unknown>
isLoading: boolean
fetch: (pond: string) => Promise<void>
}
const useFishStore = create<FishState>((set) => ({
fishies: {},
isLoading: false,
fetch: async (pond) => {
set({ isLoading: true })
try {
const response = await fetch(pond)
const data = await response.json()
set({ fishies: data, isLoading: false })
} catch {
set({ isLoading: false })
}
},
}))
7. ミドルウェア — subscribeWithSelector
セレクター付きの subscribe を使いたい場合は、subscribeWithSelector ミドルウェアを適用します。
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
interface DogState {
paw: boolean
snout: boolean
}
const useDogStore = create<DogState>()(
subscribeWithSelector(() => ({
paw: true,
snout: true,
})),
)
// paw の変更だけを購読
const unsub = useDogStore.subscribe(
(state) => state.paw,
(paw, prevPaw) => {
console.log('paw changed:', prevPaw, '->', paw)
},
)
類似パッケージとの比較
| 特徴 | zustand | Redux Toolkit | Jotai | Valtio |
|---|---|---|---|---|
| バンドルサイズ | ~1KB | ~11KB | ~3KB | ~4KB |
| ボイラープレート | 極少 | 少(v4以前は多) | 極少 | 極少 |
| Provider必要 | ❌ 不要 | ✅ 必要 | ✅ 必要 | ❌ 不要 |
| 状態モデル | ストア単位 | ストア単位(Slice) | アトム単位 | プロキシベース |
| ミドルウェア | ✅ | ✅ 豊富 | △ | △ |
| DevTools対応 | ✅(ミドルウェア) | ✅ ネイティブ | ✅ | ✅ |
| 学習コスト | 低い | 中程度 | 低い | 低い |
| React外での利用 | ✅ 容易 | △ | ❌ | ✅ |
Jotai・Valtioはzustandと同じpmndrsチームが開発しています。状態がアトム(個別の値)単位ならJotai、ミュータブルな書き方が好みならValtio、ストア単位で管理したいならzustandが適しています。
注意点・Tips
1. セレクターは必ず使う
// ❌ 全状態を取得 → あらゆる変更で再レンダリング
const state = useBearStore()
// ✅ 必要な値だけセレクターで取得
const bears = useBearStore((state) => state.bears)
セレクターなしの呼び出しはすべての状態変更で再レンダリングが発生します。パフォーマンスを意識するなら必ずセレクターを使いましょう。
2. 状態は不変(イミュータブル)に更新する
set 内でオブジェクトや配列を直接変更してはいけません。スプレッド構文や Array.prototype.filter などで新しい参照を作ってください。
// ❌ ミュータブルな更新 — 再レンダリングが発生しない
set((state) => {
state.todos.push('new todo')
return state
})
// ✅ イミュータブルな更新
set((state) => ({
todos: [...state.todos, 'new todo'],
}))
ネストが深い場合は Immer ミドルウェア を使うと便利です。
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
immer((set) => ({
deep: { nested: { count: 0 } },
increment: () =>
set((state) => {
state.deep.nested.count++ // Immerならミュータブルに書ける
}),
})),
)
3. v5でのTypeScript型定義の書き方
v5では create にジェネリクスを渡す際、カリー化された呼び出し create<State>()((set) => ...) が推奨されています(ミドルウェア使用時)。
// ミドルウェアなし — どちらでもOK
const useStore = create<MyState>((set) => ({ ... }))
// ミドルウェアあり — ()() の形にする
const useStore = create<MyState>()(
devtools(
persist((set) => ({ ... }), { name: 'my-store' })
)
)
4. DevToolsとの連携
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useStore = create<BearState>()(
devtools(
(set) => ({
bears: 0,
increasePopulation: () =>
set(
(state) => ({ bears: state.bears + 1 }),
undefined,
'increasePopulation', // アクション名をDevToolsに表示
),
}),
{ name: 'BearStore' },
),
)
5. ストアの分割
大規模アプリケーションではストアをSliceパターンで分割できます。
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fishes: number
addFish: () => void
}
const createBearSlice = (set: any): BearSlice => ({
bears: 0,
addBear: () => set((state: any) => ({ bears: state.bears + 1 })),
})
const createFishSlice = (set: any): FishSlice => ({
fishes: 0,
addFish: () => set((state: any) => ({ fishes: state.fishes + 1 })),
})
const useBoundStore = create<BearSlice & FishSlice>()((...args) => ({
...createBearSlice(...args),
...createFishSlice(...args),
}))
まとめ
zustandは「ストアを作ってフックで使う」というシンプルな設計で、Reactの状態管理における複雑さを大幅に削減してくれるライブラリです。Providerが不要、バンドルサイズが極小、TypeScriptとの相性も良好と、実用面でのメリットが非常に多くあります。Reduxからの移行先としても、新規プロジェクトの第一選択としても、安心して採用できる