Zustand vs Redux — React状態管理ライブラリ徹底比較
1. 結論
小〜中規模のプロジェクトや、ボイラープレートを最小限にしたい場合は Zustand を選んでください。大規模チーム開発で厳格なアーキテクチャ・豊富なミドルウェア・DevTools連携が必要な場合は Redux(Redux Toolkit) が依然として堅実な選択です。どちらも本番運用に十分耐えうる品質ですが、2024年以降の新規プロジェクトでは Zustand を第一候補にするチームが増えています。
2. 比較表
| 観点 | Zustand | Redux (Redux Toolkit) |
|---|---|---|
| npm 週間DL数 | 約 500万+ | 約 900万+ |
| バンドルサイズ (minified+gzip) | 約 1.1 kB | 約 11 kB(RTK含む) |
| TypeScript 対応 | ◎ ネイティブ対応 | ◎ RTK で大幅改善 |
| ボイラープレート | 極めて少ない | RTK で削減されたが依然多め |
| 学習コスト | 低い | 中〜高(概念が多い) |
| DevTools | Redux DevTools に接続可能 | Redux DevTools 完全対応 |
| ミドルウェア | immer, persist, devtools 等 | 豊富(thunk, saga, listener等) |
| React 外での利用 | ◎ フレームワーク非依存 | ◎ フレームワーク非依存 |
| SSR 対応 | ○(手動設定が必要) | ○(Next.js 向けラッパーあり) |
| コミュニティ規模 | 成長中 | 非常に大きい |
| GitHub Stars | 約 50k+ | 約 61k+ |
| 初回リリース | 2019年 | 2015年 |
| 主なメンテナー | Daishi Kato (pmndrs) | Mark Erikson (Redux team) |
3. それぞれの強み
🐻 Zustand の強み
- 圧倒的に少ないボイラープレート: Provider 不要、Action Type 定義不要。
create()一発でストアが完成します - 超軽量: gzip 後わずか約 1 kB。バンドルサイズに敏感なプロジェクトに最適です
- 直感的な API: React の
useStateに近い感覚で使えるため、チームへの導入障壁が低いです - セレクタベースの再レンダリング最適化: デフォルトで必要なステートだけを購読し、不要な再レンダリングを防ぎます
- React 外でも利用可能:
getState()/setState()で React コンポーネント外からもアクセスできます - 柔軟なミドルウェア合成:
immer,persist,devtoolsなどを関数合成で簡潔に追加できます
🔮 Redux (Redux Toolkit) の強み
- 実績と安定性: 10年近い歴史があり、大規模プロダクションでの採用事例が豊富です
- 厳格な単方向データフロー: Action → Reducer → State の流れが明確で、デバッグやコードレビューがしやすいです
- RTK Query: API キャッシュ・データフェッチングを統合的に扱える強力なツールが組み込まれています
- 豊富なミドルウェアエコシステム: redux-saga, redux-observable など、複雑な非同期フローに対応できます
- Redux DevTools のフル活用: タイムトラベルデバッグ、Action のリプレイなど、デバッグ体験が非常に優れています
- 公式ドキュメントの充実: チュートリアル、ベストプラクティス、スタイルガイドが体系的に整備されています
4. コード例で比較
題材: Todo リストの状態管理
追加・完了トグル・フィルタリングを持つシンプルな Todo アプリで比較します。
🐻 Zustand 版
// store/todoStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
// ---- 型定義 ----
interface Todo {
id: string
text: string
completed: boolean
}
type Filter = 'all' | 'active' | 'completed'
interface TodoState {
todos: Todo[]
filter: Filter
// Actions
addTodo: (text: string) => void
toggleTodo: (id: string) => void
setFilter: (filter: Filter) => void
// Derived (getter)
getFilteredTodos: () => Todo[]
}
// ---- ストア作成 ----
export const useTodoStore = create<TodoState>()(
devtools(
persist(
immer((set, get) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => {
state.todos.push({
id: crypto.randomUUID(),
text,
completed: false,
})
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.completed = !todo.completed
}),
setFilter: (filter) => set({ filter }),
getFilteredTodos: () => {
const { todos, filter } = get()
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed)
case 'completed':
return todos.filter((t) => t.completed)
default:
return todos
}
},
})),
{ name: 'todo-storage' }
)
)
)
// components/TodoApp.tsx
import { useTodoStore } from '../store/todoStore'
import { useState } from 'react'
export const TodoApp = () => {
const [input, setInput] = useState('')
// セレクタで必要な値だけ購読(再レンダリング最適化)
const addTodo = useTodoStore((s) => s.addTodo)
const toggleTodo = useTodoStore((s) => s.toggleTodo)
const setFilter = useTodoStore((s) => s.setFilter)
const filter = useTodoStore((s) => s.filter)
const filteredTodos = useTodoStore((s) => s.getFilteredTodos())
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim()) {
addTodo(input.trim())
setInput('')
}
}
return (
<div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">追加</button>
</form>
<div>
{(['all', 'active', 'completed'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
<ul>
{filteredTodos.map((todo) => (
<li
key={todo.id}
onClick={() => toggleTodo(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer',
}}
>
{todo.text}
</li>
))}
</ul>
</div>
)
}
ポイント: Provider のラップが不要。ストア定義〜コンポーネント利用まで約 80 行で完結しています。
🔮 Redux Toolkit 版
// store/todoSlice.ts
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit'
// ---- 型定義 ----
interface Todo {
id: string
text: string
completed: boolean
}
type Filter = 'all' | 'active' | 'completed'
interface TodoState {
todos: Todo[]
filter: Filter
}
const initialState: TodoState = {
todos: [],
filter: 'all',
}
// ---- Slice 作成 ----
const todoSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({
id: crypto.randomUUID(),
text: action.payload,
completed: false,
})
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.todos.find((t) => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
setFilter: (state, action: PayloadAction<Filter>) => {
state.filter = action.payload
},
},
})
export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer
// ---- メモ化セレクタ ----
const selectTodos = (state: { todos: TodoState }) => state.todos.todos
const selectFilter = (state: { todos: TodoState }) => state.todos.filter
export const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed)
case 'completed':
return todos.filter((t) => t.completed)
default:
return todos
}
}
)
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import todoReducer from './todoSlice'
export const store = configureStore({
reducer: {
todos: todoReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
// store/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './index'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// components/TodoApp.tsx
import { useState } from 'react'
import { useAppDispatch, useAppSelector } from '../store/hooks'
import {
addTodo,
toggleTodo,
setFilter,
selectFilteredTodos,
} from '../store/todoSlice'
export const TodoApp = () => {
const [input, setInput] = useState('')
const dispatch = useAppDispatch()
const filter = useAppSelector((s) => s.todos.filter)
const filteredTodos = useAppSelector(selectFilteredTodos)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim()) {
dispatch(addTodo(input.trim()))
setInput('')
}
}
return (
<div>
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit">追加</button>
</form>
<div>
{(['all', 'active', 'completed'] as const).map((f) => (
<button
key={f}
onClick={() => dispatch(setFilter(f))}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
<ul>
{filteredTodos.map((todo) => (
<li
key={todo.id}
onClick={() => dispatch(toggleTodo(todo.id))}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer',
}}
>
{todo.text}
</li>
))}
</ul>
</div>
)
}
// main.tsx(Provider が必要)
import { Provider } from 'react-redux'
import { store } from './store'
import { TodoApp } from './components/TodoApp'
const App = () => (
<Provider store={store}>
<TodoApp />
</Provider>
)
ポイント: RTK のおかげで旧来の Redux より大幅に簡潔になりましたが、slice / store / hooks / Provider と複数ファイルにまたがる構成が必要です。一方で
createSelectorによるメモ化や、configureStoreによる DevTools 自動統合は強力です。
コード量の比較
| 項目 | Zustand | Redux Toolkit |
|---|---|---|
| ストア定義 | 1ファイル(約 40行) | 3ファイル(slice + store + hooks で約 60行) |
| Provider 設定 | 不要 | 必要 |
| コンポーネント側 | ほぼ同等 | ほぼ同等(dispatch 経由) |
| 合計行数(概算) | 約 80行 | 約 110行 |
5. どちらを選ぶべきか — ユースケース別ガイド
✅ Zustand を選ぶべきケース
| ユースケース | 理由 |
|---|---|
| 新規の小〜中規模プロジェクト | 最小限のセットアップで即座に開発を始められます |
| バンドルサイズが重要(モバイルWeb等) | 約 1 kB は Redux の 1/10 以下です |
| プロトタイピング・MVP 開発 | ボイラープレートが少なく、素早くイテ |