zustand vs redux 徹底比較

zustand の詳細redux の詳細
AI生成コンテンツ

この記事はAIによって生成されました。内容の正確性は保証されません。最新の情報は公式ドキュメントをご確認ください。

Zustand vs Redux — React状態管理ライブラリ徹底比較

1. 結論

小〜中規模のプロジェクトや、ボイラープレートを最小限にしたい場合は Zustand を選んでください。大規模チーム開発で厳格なアーキテクチャ・豊富なミドルウェア・DevTools連携が必要な場合は Redux(Redux Toolkit) が依然として堅実な選択です。どちらも本番運用に十分耐えうる品質ですが、2024年以降の新規プロジェクトでは Zustand を第一候補にするチームが増えています。


2. 比較表

観点ZustandRedux (Redux Toolkit)
npm 週間DL数約 500万+約 900万+
バンドルサイズ (minified+gzip)約 1.1 kB約 11 kB(RTK含む)
TypeScript 対応◎ ネイティブ対応◎ RTK で大幅改善
ボイラープレート極めて少ないRTK で削減されたが依然多め
学習コスト低い中〜高(概念が多い)
DevToolsRedux 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 自動統合は強力です。


コード量の比較

項目ZustandRedux Toolkit
ストア定義1ファイル(約 40行)3ファイル(slice + store + hooks で約 60行)
Provider 設定不要必要
コンポーネント側ほぼ同等ほぼ同等(dispatch 経由)
合計行数(概算)約 80行約 110行

5. どちらを選ぶべきか — ユースケース別ガイド

✅ Zustand を選ぶべきケース

ユースケース理由
新規の小〜中規模プロジェクト最小限のセットアップで即座に開発を始められます
バンドルサイズが重要(モバイルWeb等)約 1 kB は Redux の 1/10 以下です
プロトタイピング・MVP 開発ボイラープレートが少なく、素早くイテ