状態管理(Zustand)
この章では、React 開発で人気の状態管理ライブラリ「Zustand」を学びます。
この章で学ぶこと
- Zustandの基本的な使い方
- セレクターによる再レンダリング最適化
- ミドルウェア(persist、devtools)
- useContextとの使い分け
この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。
なぜZustandか
状態管理の選択肢
| ライブラリ | 特徴 |
|---|---|
| useState | ローカル状態向け |
| useContext | 軽量なグローバル状態 |
| Redux | 大規模アプリ向け、設定が複雑 |
| Zustand | 軽量、シンプルなAPI |
| Jotai | アトミックな状態管理 |
Zustandは、Reduxの複雑さを避けつつ、グローバルな状態管理が必要な場面で最適な選択肢です。
useContext(13章)でもグローバル状態は実現できますが、Zustandを選ぶ主な理由は以下の通りです:
- 再レンダリング最適化: セレクターで必要な部分のみ購読できる
- ボイラープレートの少なさ: Provider不要、1ファイルで完結
- DevTools対応: Redux DevToolsで状態を可視化
- ミドルウェア: 永続化やログ出力を簡単に追加
セットアップ
npm install zustand
本書ではZustand v5(執筆時点 5.0.14)を使用しています。メジャーバージョンが異なる場合、APIが変更されている可能性があります。
Zustand v5 の TypeScript 推奨記法について
本書のサンプルでは読みやすさを優先して create<T>((set) => ...) と書いていますが、v5 以降のミドルウェアを併用する場合は、型推論の都合上 create<T>()(...) の currying 形式(2段階の関数呼び出し)が推奨されます。
// 推奨: currying 形式(ミドルウェアとの型推論が安定)
const useStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// persist と組み合わせる場合
const useStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'settings-storage' }
)
)
ミドルウェアを使わないシンプルなストアでは、本書記載の create<T>((set) => ...) でも正しく動作します。
セレクタで必要な値だけを購読する
const { count, increment } = useCounterStore() のように全体を取り出すと、どのフィールドが変わってもこのコンポーネントが再レンダリングされます。パフォーマンスが気になる場合は、セレクタで必要な値だけを取り出してください。
// 必要な値だけ選択(再レンダリングを最小化)
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
// 複数の値を選択するときは useShallow でオブジェクト比較
import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
)
基本的な使い方
ストアの作成
import { create } from 'zustand'
type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
const useCounterStore = create<CounterStore>((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, increment, decrement, reset } = useCounterStore()
return (
<div>
<p>カウント: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>リセット</button>
</div>
)
}
試してみよう: カウンターストアの作成
count、increment、decrement、resetを持つカウンターストアを作成し、ボタンで操作できるようにしてみましょう。
ヒント
create関数でcount状態と更新関数を定義set((state) => ({ count: state.count + 1 }))のパターンで状態を更新- Provider不要でどこからでも使用可能
回答と解説
// src/stores/counterStore.ts
import { create } from 'zustand'
type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))
// src/App.tsx
import { useCounterStore } from './stores/counterStore'
function App() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Zustand カウンター</h1>
<p className="text-4xl font-bold mb-4">{count}</p>
<div className="space-x-2">
<button
onClick={decrement}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
-1
</button>
<button
onClick={increment}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
+1
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
リセット
</button>
</div>
</div>
)
}
export default App
Zustandのcreate関数は、状態とアクションを含むオブジェクトを返します。set関数に新しい状態を渡すか、前の状態から新しい状態を計算する関数を渡します。Provider不要でどこからでも使用できます。
セレクターによる最適化
必要な状態のみを購読することで、不要な再レンダリングを防げます。
オブジェクト全体を分割代入で取得すると、どの値が変更されても再レンダリングされます。パフォーマンスを意識する場合は、必ずセレクターを使用してください。
// 全ての状態を購読(countが変わると再レンダリング)
const { count, increment } = useCounterStore()
// countのみを購読
const count = useCounterStore((state) => state.count)
// incrementのみを購読(関数は変わらないので再レンダリングなし)
const increment = useCounterStore((state) => state.increment)
複数の値を選択
// shallow比較で複数の値を購読
import { useShallow } from 'zustand/react/shallow'
const { count, total } = useCounterStore(
useShallow((state) => ({
count: state.count,
total: state.total
}))
)
試してみよう: セレクターの活用
カウンターコンポーネントをCountDisplay(表示のみ)とCountButtons(操作のみ)に分割し、セレクターで必要な値のみを購読してみましょう。
ヒント
useCounterStore((state) => state.count)でcountのみ購読- アクション関数は変わらないので再レンダリングされない
console.logを入れて再レンダリングを確認
回答と解説
// src/components/CountDisplay.tsx
import { useCounterStore } from '../stores/counterStore'
export function CountDisplay() {
// countのみを購読(countが変わったときだけ再レンダリング)
const count = useCounterStore((state) => state.count)
console.log('CountDisplay rendered')
return (
<div className="text-6xl font-bold text-center p-8 bg-gray-100 rounded-lg">
{count}
</div>
)
}
// src/components/CountButtons.tsx
import { useCounterStore } from '../stores/counterStore'
export function CountButtons() {
// アクションのみを購読(関数は変わらないので再レンダリングされない)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
console.log('CountButtons rendered')
return (
<div className="flex gap-2 justify-center">
<button
onClick={decrement}
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
-1
</button>
<button
onClick={increment}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
+1
</button>
<button
onClick={reset}
className="px-6 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
>
リセット
</button>
</div>
)
}
// src/App.tsx
import { CountDisplay } from './components/CountDisplay'
import { CountButtons } from './components/CountButtons'
function App() {
return (
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-center">セレクター最適化</h1>
<CountDisplay />
<CountButtons />
</div>
)
}
export default App
コンソールを確認すると、ボタンをクリックしてもCountButtonsは再レンダリングされず、CountDisplayのみが更新されることがわかります。セレクターを使うことで、必要な部分だけが再レンダリングされます。
実践的なストア設計
Todoアプリのストア
type Todo = {
id: string
text: string
completed: boolean
}
type TodoStore = {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
removeTodo: (id: string) => void
clearCompleted: () => void
}
const useTodoStore = create<TodoStore>((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text,
completed: false
}
]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
removeTodo: (id) => set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
})),
clearCompleted: () => set((state) => ({
todos: state.todos.filter((todo) => !todo.completed)
}))
}))
使用例
function TodoList() {
const todos = useTodoStore((state) => state.todos)
const toggleTodo = useTodoStore((state) => state.toggleTodo)
const removeTodo = useTodoStore((state) => state.removeTodo)
const createToggleHandler = (id: string) => () => {
toggleTodo(id)
}
const createRemoveHandler = (id: string) => () => {
removeTodo(id)
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={createToggleHandler(todo.id)}
/>
<span className={todo.completed ? 'line-through' : ''}>
{todo.text}
</span>
<button onClick={createRemoveHandler(todo.id)}>削除</button>
</li>
))}
</ul>
)
}
function AddTodo() {
const [text, setText] = useState('')
const addTodo = useTodoStore((state) => state.addTodo)
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (text.trim()) {
addTodo(text)
setText('')
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={handleTextChange}
placeholder="新しいTodo"
/>
<button type="submit">追加</button>
</form>
)
}
非同期アクション
type UserStore = {
user: User | null
loading: boolean
error: string | null
fetchUser: (id: string) => Promise<void>
}
const useUserStore = create<UserStore>((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
set({ user, loading: false })
} catch (error) {
set({ error: 'ユーザーの取得に失敗しました', loading: false })
}
}
}))
ミドルウェア
persist(永続化)
import { persist } from 'zustand/middleware'
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme })
}),
{
name: 'settings-storage' // localStorageのキー
}
)
)
devtools(Redux DevTools連携)
import { devtools } from 'zustand/middleware'
const useStore = create<StoreType>()(
devtools(
(set) => ({
// ...
}),
{ name: 'MyStore' }
)
)
ミドルウェアの組み合わせ
const useStore = create<StoreType>()(
devtools(
persist(
(set) => ({
// ...
}),
{ name: 'storage-key' }
),
{ name: 'DevToolsName' }
)
)
試してみよう: persist追加
カウンターの値をlocalStorageに保存し、ページをリロードしても値が保持されるようにしてみましょう。
ヒント
persistミドルウェアでcreateをラップnameオプションでlocalStorageのキーを指定- DevToolsで「Application」→「Local Storage」を確認
回答と解説
// src/stores/counterStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export const useCounterStore = create<CounterStore>()(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}),
{
name: 'counter-storage' // localStorageのキー
}
)
)
// src/App.tsx
import { useCounterStore } from './stores/counterStore'
function App() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">永続化カウンター</h1>
<p className="text-4xl font-bold mb-4">{count}</p>
<div className="space-x-2">
<button
onClick={decrement}
className="px-4 py-2 bg-red-500 text-white rounded"
>
-1
</button>
<button
onClick={increment}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
+1
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-500 text-white rounded"
>
リセット
</button>
</div>
<p className="mt-4 text-gray-500">
ページをリロードしても値が保持されます
</p>
</div>
)
}
export default App
persistミドルウェアは自動的にlocalStorageと同期します。ブラウザのDevToolsで「Application」→「Local Storage」を確認すると、counter-storageというキーでデータが保存されていることがわかります。ページをリロードしても、前回の値から再開できます。
immerミドルウェア
ネストしたオブジェクトの更新を簡潔に書くには、immerミドルウェアが使えます。
npm install immer
import { immer } from 'zustand/middleware/immer'
type StoreType = {
user: {
profile: {
name: string
email: string
}
}
setUserName: (name: string) => void
}
const useStore = create<StoreType>()(
immer((set) => ({
user: {
profile: {
name: '',
email: ''
}
},
// immerなし: スプレッド構文のネストが深くなる
// setUser: (name) => set((state) => ({
// user: { ...state.user, profile: { ...state.user.profile, name } }
// }))
// immerあり: 直接変更するように書ける
setUserName: (name) => set((state) => {
state.user.profile.name = name
})
}))
)
Immerは内部でイミュータブルに更新するため、07章で学んだ「状態を直接変更しない」原則は守られています。
getとset
ストア外からも状態にアクセスできます。
const useStore = create<Store>((set, get) => ({
count: 0,
doubleCount: () => get().count * 2, // getで現在の状態を取得
increment: () => set((state) => ({ count: state.count + 1 }))
}))
// コンポーネント外からのアクセス
const currentCount = useStore.getState().count
useStore.setState({ count: 10 })
Zustand vs Context API
| 項目 | Zustand | Context API |
|---|---|---|
| 再レンダリング | セレクターで最適化 | Context を読む全コンポーネント(部分購読は不可) |
| ボイラープレート | 少ない | 多い |
| DevTools | 対応 | なし |
| 永続化 | middleware対応 | 自前実装 |
まとめ
- Zustandは軽量でシンプルな状態管理ライブラリ
createでストアを作成、フックとして使用- セレクターで必要な状態のみ購読し最適化
- ミドルウェアで永続化やDevTools連携が可能
- 複雑な設定不要で、すぐに使い始められる
次の章では、TanStack Queryによるデータフェッチングを学びます。