メインコンテンツまでスキップ

状態管理(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>
)
}

試してみよう: カウンターストアの作成

countincrementdecrementresetを持つカウンターストアを作成し、ボタンで操作できるようにしてみましょう。

ヒント
  1. create関数でcount状態と更新関数を定義
  2. set((state) => ({ count: state.count + 1 }))のパターンで状態を更新
  3. 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(操作のみ)に分割し、セレクターで必要な値のみを購読してみましょう。

ヒント
  1. useCounterStore((state) => state.count)でcountのみ購読
  2. アクション関数は変わらないので再レンダリングされない
  3. 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に保存し、ページをリロードしても値が保持されるようにしてみましょう。

ヒント
  1. persistミドルウェアでcreateをラップ
  2. nameオプションでlocalStorageのキーを指定
  3. 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

項目ZustandContext API
再レンダリングセレクターで最適化Context を読む全コンポーネント(部分購読は不可)
ボイラープレート少ない多い
DevTools対応なし
永続化middleware対応自前実装

まとめ

  • Zustandは軽量でシンプルな状態管理ライブラリ
  • createでストアを作成、フックとして使用
  • セレクターで必要な状態のみ購読し最適化
  • ミドルウェアで永続化やDevTools連携が可能
  • 複雑な設定不要で、すぐに使い始められる

次の章では、TanStack Queryによるデータフェッチングを学びます。

次に読む