Garden に戻る

Redux / Zustand / Jotai 実装比較ガイド

React アプリでよく利用される Redux・Zustand・Jotai の実装パターンをユースケース別に比較し、選定の視点を整理したガイドです。

公開日 読了目安 6 分

はじめに

React で状態管理を設計する際、「どのライブラリを選ぶべきか」はプロダクトの開発体験を大きく左右します。長年スタンダードだった Redux は堅牢ですが、アクションやリデューサーの定義がかさみやすいのが悩みどころです。一方、近年は Zustand や Jotai のような軽量な選択肢が登場し、小〜中規模の案件では採用例が増えています。

この記事では代表的な 5 つのユースケースを軸に、Redux・Zustand・Jotai の書き味と思想の違いを具体的なコードで比較します。用途に応じて選択できるよう、最後に評価ポイントもまとめました。

参考: npm trends
npm-trends
jotai vs redux vs zustand | npm trends


1. カウンター: 最小構成での差分

ストア/atom 実装

Redux

// actionTypes.ts
export const INCREMENT = "INCREMENT"

// actions.ts
export const increment = () => ({ type: INCREMENT })

// reducer.ts
import { INCREMENT } from "./actionTypes"

const initialState = { count: 0 }

export function counterReducer(state = initialState, action: any) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 }
    default:
      return state
  }
}

Zustand

import { create } from "zustand"

export const useCounterStore = create(set => ({
  count: 0,
  increment: () => set(s => ({ count: s.count + 1 })),
}))

Jotai

import { atom } from "jotai"

export const countAtom = atom(0)
export const incrementAtom = atom(null, (_, set) =>
  set(countAtom, c => c + 1),
)

コンポーネントからの利用

// Redux
const count = useSelector((s: any) => s.counter.count)
const dispatch = useDispatch()
;<button onClick={() => dispatch(increment())}>+1</button>

// Zustand
const { count, increment } = useCounterStore()
;<button onClick={increment}>+1</button>

// Jotai
const [count] = useAtom(countAtom)
const [, increment] = useAtom(incrementAtom)
;<button onClick={increment}>+1</button>

Redux ではアクション/リデューサーを明示的に分離するため構造はわかりやすいものの、ボイラープレートが増えます。Zustand と Jotai は 1 ファイルで完結し、React Hooks の感覚で扱える点が強みです。


2. 非同期データ取得(Todo リスト)

Redux(redux-thunk)

// store.ts
import { createStore, applyMiddleware } from "redux"
import thunk from "redux-thunk"
import { todoReducer } from "./todoReducer"

export const store = createStore(todoReducer, applyMiddleware(thunk))

// actions.ts
export const FETCH_START = "FETCH_START"
export const FETCH_SUCCESS = "FETCH_SUCCESS"
export const FETCH_ERROR = "FETCH_ERROR"

export const fetchTodos = () => async (dispatch: any) => {
  dispatch({ type: FETCH_START })
  try {
    const res = await fetch("/api/todos")
    const data = await res.json()
    dispatch({ type: FETCH_SUCCESS, payload: data })
  } catch (e: any) {
    dispatch({ type: FETCH_ERROR, error: e.message })
  }
}

// reducer.ts
const initialState = { todos: [], loading: false, error: null }

export function todoReducer(state = initialState, action: any) {
  switch (action.type) {
    case FETCH_START:
      return { ...state, loading: true }
    case FETCH_SUCCESS:
      return { todos: action.payload, loading: false }
    case FETCH_ERROR:
      return { ...state, loading: false, error: action.error }
    default:
      return state
  }
}

Redux はミドルウェア(ここでは redux-thunk)を挟むことで、副作用をアクションフローとして安全に管理できます。チームでの制御が厳密なほど価値が高まります。

Zustand(非同期関数を直接定義)

import { create } from "zustand"

export const useTodoStore = create(set => ({
  todos: [],
  loading: false,
  error: null,
  fetchTodos: async () => {
    set({ loading: true })
    try {
      const res = await fetch("/api/todos")
      const data = await res.json()
      set({ todos: data, loading: false })
    } catch (e: any) {
      set({ error: e.message, loading: false })
    }
  },
}))

Zustand は store に非同期関数をそのまま記述できます。必要最低限のコードで「状態+副作用」を定義でき、学習コストも低いのが特徴です。

Jotai(副作用 atom)

import { atom } from "jotai"

export const todosAtom = atom<any[]>([])
export const loadingAtom = atom(false)
export const fetchTodosAtom = atom(null, async (_, set) => {
  set(loadingAtom, true)
  const res = await fetch("/api/todos")
  const data = await res.json()
  set(todosAtom, data)
  set(loadingAtom, false)
})

Jotai は副作用を atom として宣言し、依存グラフに組み込めます。データ取得と状態更新を宣言的に表現でき、再評価の最適化も自動で行われます。

コンポーネントでの呼び出し比較

// Redux
const { todos, loading } = useSelector((s: any) => s.todos)
const dispatch = useDispatch()
useEffect(() => {
  dispatch(fetchTodos())
}, [dispatch])

// Zustand
const { todos, loading, fetchTodos } = useTodoStore()
useEffect(() => {
  fetchTodos()
}, [fetchTodos])

// Jotai
const [todos] = useAtom(todosAtom)
const [loading] = useAtom(loadingAtom)
const [, fetchTodos] = useAtom(fetchTodosAtom)
useEffect(() => {
  fetchTodos()
}, [fetchTodos])

3. フォーム入力管理

状態管理側の実装

Redux

// actions.ts
export const SET_NAME = "SET_NAME"
export const SET_EMAIL = "SET_EMAIL"
export const setName = (name: string) => ({ type: SET_NAME, payload: name })
export const setEmail = (email: string) => ({
  type: SET_EMAIL,
  payload: email,
})

// reducer.ts
const initialState = { name: "", email: "" }

export function formReducer(state = initialState, action: any) {
  switch (action.type) {
    case SET_NAME:
      return { ...state, name: action.payload }
    case SET_EMAIL:
      return { ...state, email: action.payload }
    default:
      return state
  }
}

Zustand

import { create } from "zustand"

export const useFormStore = create(set => ({
  name: "",
  email: "",
  setName: (name: string) => set({ name }),
  setEmail: (email: string) => set({ email }),
}))

Jotai

import { atom } from "jotai"

export const nameAtom = atom("")
export const emailAtom = atom("")

コンポーネント側の実装

// Redux
const { name, email } = useSelector((s: any) => s.form)
const dispatch = useDispatch()
;<input value={name} onChange={e => dispatch(setName(e.target.value))} />
;<input value={email} onChange={e => dispatch(setEmail(e.target.value))} />

// Zustand
const { name, email, setName, setEmail } = useFormStore()
;<input value={name} onChange={e => setName(e.target.value)} />
;<input value={email} onChange={e => setEmail(e.target.value)} />

// Jotai
const [name, setName] = useAtom(nameAtom)
const [email, setEmail] = useAtom(emailAtom)
;<input value={name} onChange={e => setName(e.target.value)} />
;<input value={email} onChange={e => setEmail(e.target.value)} />

フォームのバインディングの量を見ると、Redux がもっとも構造的で、Zustand・Jotai は最低限の記述で済むことがわかります。


4. モーダル開閉

状態管理側

Redux

// actions.ts
export const OPEN_MODAL = "OPEN_MODAL"
export const CLOSE_MODAL = "CLOSE_MODAL"
export const openModal = () => ({ type: OPEN_MODAL })
export const closeModal = () => ({ type: CLOSE_MODAL })

// reducer.ts
const initialState = { isOpen: false }

export function modalReducer(state = initialState, action: any) {
  switch (action.type) {
    case OPEN_MODAL:
      return { isOpen: true }
    case CLOSE_MODAL:
      return { isOpen: false }
    default:
      return state
  }
}

Zustand

import { create } from "zustand"

export const useModalStore = create(set => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}))

Jotai

import { atom } from "jotai"

export const modalAtom = atom(false)

コンポーネント側

// Redux
const isOpen = useSelector((s: any) => s.modal.isOpen)
const dispatch = useDispatch()
;<button onClick={() => dispatch(openModal())}>Open</button>
{isOpen && <button onClick={() => dispatch(closeModal())}>Close</button>}

// Zustand
const { isOpen, open, close } = useModalStore()
;<button onClick={open}>Open</button>
{isOpen && <button onClick={close}>Close</button>}

// Jotai
const [isOpen, setOpen] = useAtom(modalAtom)
;<button onClick={() => setOpen(true)}>Open</button>
{isOpen && <button onClick={() => setOpen(false)}>Close</button>}

Zustand と Jotai はボタンイベントのバインディングが直感的で、Redux は明示的なアクションを定義する代わりにテストしやすさと拡張性を得ています。


5. 状態間の依存

複数の状態が相互に影響するケースはライブラリの思想が最も現れます。

Redux(selector)

// userReducer.ts
const initialUser = { name: "Alice" }
export function userReducer(state = initialUser, _action: any) {
  return state
}

// profileReducer.ts
const initialProfile = { message: "" }
export function profileReducer(state = initialProfile, _action: any) {
  return state
}

// selectors.ts
import { createSelector } from "reselect"

export const selectUser = (state: any) => state.user
export const selectUserName = createSelector([selectUser], u => u.name)

export const selectUserMessage = createSelector([selectUserName], name => {
  return `Hello from ${name}`
})

Zustand(store 間参照)

import { create } from "zustand"

export const useUserStore = create(() => ({
  name: "Alice",
}))

export const useProfileStore = create(set => ({
  message: "",
  setMessageFromUser: () => {
    const user = useUserStore.getState().name
    set({ message: `Hello from ${user}` })
  },
}))

Jotai(依存 atom)

import { atom } from "jotai"

export const userAtom = atom({ name: "Alice" })
export const messageAtom = atom(get => `Hello from ${get(userAtom).name}`)

表示

// Redux
const greeting = useSelector(selectUserMessage)
;<p>{greeting}</p> // "Hello from Alice"

// Zustand
const { message, setMessageFromUser } = useProfileStore()
;<button onClick={setMessageFromUser}>Set message</button>
;<p>{message}</p> // "Hello from Alice"

// Jotai
const [message] = useAtom(messageAtom)
;<p>{message}</p> // "Hello from Alice"

Redux は selector を通して依存関係を明示的に保ち、Zustand は getState() で柔軟に参照、Jotai は依存グラフを宣言的に扱えます。


6. Redux の combineReducers で状態を統合

// rootReducer.ts
import { combineReducers } from "redux"
import { counterReducer } from "./counterReducer"
import { todoReducer } from "./todoReducer"
import { formReducer } from "./formReducer"
import { modalReducer } from "./modalReducer"

export const rootReducer = combineReducers({
  counter: counterReducer,
  todos: todoReducer,
  form: formReducer,
  modal: modalReducer,
})
// store.ts
import { createStore } from "redux"
import { rootReducer } from "./rootReducer"

export const store = createStore(rootReducer)

Redux はアプリ全体で状態を統合する構造を持ち、スライスごとの責務が明瞭です。Zustand と Jotai はそれぞれ独立した store/atom を組み合わせて疎結合に保つため、小粒度の調整がしやすい一方、全体像の管理は開発者に委ねられます。


7. メリット・デメリット比較

観点 Redux Zustand Jotai
学習コスト 高い 低い 中程度
コード量 多い 少ない 少ない
パフォーマンス 良 優 優
拡張の方向性 Middleware によるアーキテクチャ拡張 store ミドルウェア合成による機能拡張 atom 依存とユーティリティ拡張
他 state 参照 selector で安全に依存 get 関数で参照可 依存関係を宣言的に定義

学習コスト

  • Redux: アクション・リデューサー・ストアの概念に加え、副作用処理では redux-thunk や redux-saga など追加学習が必要です。Redux Toolkit で軽減されつつも、設計思想を理解するには時間がかかります。
  • Zustand: create で状態とロジックを定義するだけなので直感的です。React Hooks と似た API により、初学者にも取り組みやすいでしょう。
  • Jotai: React の state に近い書き心地ですが、atom 間の依存や派生 atom への理解が求められます。宣言的な思考がフィットする場合に真価を発揮します。

コード量

  • Redux: 状態変更のたびに Action → Dispatch → Reducer の流れを構築するため、単純なカウンターでも複数ファイルに分散しがちです。
  • Zustand: 状態と更新処理を 1 つの store に同居させ、Hooks として呼び出せます。Action 型定義が不要で、Redux の半分以下のコード量に収まるケースが多いです。
  • Jotai: atom を 1 行で宣言し、更新処理も atom 関数に閉じ込められます。構造体よりも関数型の思考に馴染む人ほど生産性を高められます。

パフォーマンス

  • Redux(良): useSelector で購読範囲を絞れますが、immutable 更新のコストを最適化するには reselect などの工夫が必要です。
  • Zustand(優): 内部で shallow 比較を行い、React のレンダリングサイクルと独立した store が最小限の再描画を実現します。
  • Jotai(優): atom ごとに依存関係が明示され、更新対象のみが再評価されます。宣言的な依存グラフ設計がそのままパフォーマンスに直結します。

拡張性

項目 Redux Zustand Jotai
拡張手段 Middleware でアクションフローを強化 store ミドルウェアの関数合成 atom とユーティリティで依存拡張
主な用途 副作用管理、ロギング、監視、状態統制 永続化、DevTools 連携、購読最適化 永続化、非同期処理、派生ロジック拡張
設計思想 Flux パターンによる構造的拡張 Hook ベースの局所拡張 宣言的・関数的な依存拡張
拡張の焦点 アプリ全体のアーキテクチャ制御 store 単位の柔軟拡張 atom 組み合わせによるロジック拡張
  • Redux: Middleware によりアクションフローを制御しやすく、redux-thunk や redux-saga などで非同期処理や監視を体系化できます。
  • Zustand: persist, devtools, subscribeWithSelector などを合成して機能を拡張できます。Hook 感覚でミニマルに導入できる半面、設計指針は開発者に委ねられます。
  • Jotai: jotai/utils や jotai/query などで永続化や非同期処理を追加できます。(get, set) パターンにより依存グラフを柔軟に構築でき、派生ロジックを宣言的に保てます。

他 state 参照の設計

  • Redux: selector が依存関係を明示し、メモ化も容易です。Reducer 間の強い依存を避けつつ複数 state を統合できます。
  • Zustand: useStore.getState() や get 引数を使って柔軟に他 store や slice を参照できます。自由度は高いものの、依存関係の追跡は開発者が担います。
  • Jotai: (get) => ... 形式で依存を宣言するだけで、自動的に再評価タイミングが管理されます。依存グラフを整えやすいのが強みです。

まとめと選定のヒント

Redux・Zustand・Jotai はいずれも React アプリの状態管理を担えますが、設計思想・記述量・拡張の方向性 に明確な差があります。コード例からも分かる通り、同じカウンター機能でも Redux は 3 ファイル前後、Zustand は 1 ファイル、Jotai は数行で構築できます。

  • 構造を厳密に管理し、フロー全体を可視化したい → Redux
  • スピード重視で、最小限のコードで機能を届けたい → Zustand
  • 状態を細かく分割し、再レンダリングを極力抑えたい → Jotai

プロジェクトの規模やチームの習熟度、求める拡張性に照らし合わせて選ぶのが最終的なポイントです。この記事のスニペットをたたき台に、実際の要件へアレンジしてみてください。