zustand vs jotai 徹底比較

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

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

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

1. 結論

小〜中規模のグローバルストアを「1つの場所」でシンプルに管理したいなら Zustandコンポーネント単位で細粒度のアトミックな状態を組み合わせたいなら Jotai を選んでください。どちらも同じ作者(Daishi Kato 氏を中心とした pmndrs チーム)が開発しており、軽量・TypeScript フレンドリーという共通点がありますが、設計思想が根本的に異なります。


2. 比較表

観点Zustand 🐻Jotai 👻
設計思想ストア中心(Flux 系・トップダウン)アトム中心(Recoil 系・ボトムアップ)
状態の定義場所create() でストアを定義(React 外)atom() で個別に定義(React ツリー内で解決)
バンドルサイズ (minified+gzip)約 1.1 kB約 2.4 kB(core)
TypeScript 対応◎(型推論が自然に効く)◎(ジェネリクスで型安全)
React 外からのアクセス◎(getState / setState で容易)△(createStore + getDefaultStore で可能だがやや冗長)
DevToolsRedux DevTools 連携ミドルウェアReact DevTools + 専用 DevTools
ミドルウェアpersist / immer / devtools 等が公式提供拡張は派生 atom(atomWithStorage 等)で実現
再レンダリング最適化セレクタで手動最適化atom 単位で自動最適化
学習コスト★☆☆(Redux 経験者は即座に理解)★★☆(atom / derived atom の概念に慣れが必要)
SSR / Next.js 対応◎(Provider 不要で扱いやすい)◎(Provider 推奨だが省略も可)
GitHub Stars(2025年時点)約 50k+約 20k+
週間ダウンロード数約 500 万+約 200 万+

3. それぞれの強み

Zustand 🐻 の強み

  • 圧倒的なシンプルさ: create 関数ひとつでストアが完成します。ボイラープレートが極めて少なく、Redux から移行するチームでも即日導入できます。
  • React 外からのアクセスが容易: store.getState() / store.setState() で React コンポーネント外(WebSocket ハンドラ、CLI ツール、テストなど)から自由に読み書きできます。
  • Provider 不要: Context を使わないため、Provider のネスト地獄から解放されます。
  • ミドルウェアエコシステム: persist(localStorage 永続化)、immer(イミュータブル更新の簡略化)、devtools(Redux DevTools 連携)など、公式ミドルウェアが充実しています。
  • 超軽量: gzip 後わずか約 1 kB。バンドルサイズへの影響がほぼありません。

Jotai 👻 の強み

  • 細粒度の再レンダリング最適化: atom 単位でサブスクリプションが分離されるため、セレクタを書かなくても不要な再レンダリングが発生しにくい設計です。
  • ボトムアップの合成: 小さな atom を derived atom で組み合わせることで、複雑な状態を宣言的に構築できます。
  • 非同期ファーストクラス: async get を使った非同期 atom がネイティブにサポートされており、React Suspense との統合が自然です。
  • 柔軟な拡張: atomWithStorageatomWithQuery(TanStack Query 連携)、atomWithMachine(XState 連携)など、サードパーティ統合が豊富です。
  • コンポーネントローカルな状態管理: Provider を使えば同じ atom 定義でもツリーごとに独立した状態を持てます。マルチテナント UI などで威力を発揮します。

4. コード例で比較

お題: カウンターとTodoリストを持つ簡易アプリ

Zustand 🐻 版

// store.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface Todo {
  id: number
  text: string
  done: boolean
}

interface AppState {
  // --- Counter ---
  count: number
  increment: () => void
  decrement: () => void

  // --- Todos ---
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
}

export const useAppStore = create<AppState>()(
  immer((set) => ({
    // Counter
    count: 0,
    increment: () => set((state) => { state.count += 1 }),
    decrement: () => set((state) => { state.count -= 1 }),

    // Todos
    todos: [],
    addTodo: (text) =>
      set((state) => {
        state.todos.push({ id: Date.now(), text, done: false })
      }),
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id)
        if (todo) todo.done = !todo.done
      }),
  }))
)
// Counter.tsx
import { useAppStore } from './store'

export const Counter = () => {
  // セレクタで必要な値だけ購読 → 再レンダリング最適化
  const count = useAppStore((s) => s.count)
  const increment = useAppStore((s) => s.increment)
  const decrement = useAppStore((s) => s.decrement)

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  )
}
// TodoList.tsx
import { useState } from 'react'
import { useAppStore } from './store'

export const TodoList = () => {
  const todos = useAppStore((s) => s.todos)
  const addTodo = useAppStore((s) => s.addTodo)
  const toggleTodo = useAppStore((s) => s.toggleTodo)
  const [text, setText] = useState('')

  return (
    <div>
      <h2>Todos</h2>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button
        onClick={() => {
          if (text.trim()) {
            addTodo(text.trim())
            setText('')
          }
        }}
      >
        追加
      </button>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            onClick={() => toggleTodo(todo.id)}
            style={{ textDecoration: todo.done ? 'line-through' : 'none', cursor: 'pointer' }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  )
}

Jotai 👻 版

// atoms.ts
import { atom } from 'jotai'

// --- Counter ---
export const countAtom = atom(0)

// --- Todos ---
interface Todo {
  id: number
  text: string
  done: boolean
}

export const todosAtom = atom<Todo[]>([])

// 派生 atom(write-only): Todo を追加
export const addTodoAtom = atom(null, (get, set, text: string) => {
  const prev = get(todosAtom)
  set(todosAtom, [...prev, { id: Date.now(), text, done: false }])
})

// 派生 atom(write-only): Todo をトグル
export const toggleTodoAtom = atom(null, (get, set, id: number) => {
  const prev = get(todosAtom)
  set(
    todosAtom,
    prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
  )
})

// 派生 atom(read-only): 完了数を計算
export const doneCountAtom = atom((get) => {
  return get(todosAtom).filter((t) => t.done).length
})
// Counter.tsx
import { useAtom } from 'jotai'
import { countAtom } from './atoms'

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom)

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  )
}
// TodoList.tsx
import { useState } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { todosAtom, addTodoAtom, toggleTodoAtom, doneCountAtom } from './atoms'

export const TodoList = () => {
  const todos = useAtomValue(todosAtom)
  const addTodo = useSetAtom(addTodoAtom)
  const toggleTodo = useSetAtom(toggleTodoAtom)
  const doneCount = useAtomValue(doneCountAtom)
  const [text, setText] = useState('')

  return (
    <div>
      <h2>Todos(完了: {doneCount})</h2>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button
        onClick={() => {
          if (text.trim()) {
            addTodo(text.trim())
            setText('')
          }
        }}
      >
        追加
      </button>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            onClick={() => toggleTodo(todo.id)}
            style={{ textDecoration: todo.done ? 'line-through' : 'none', cursor: 'pointer' }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  )
}

コード比較のポイント

観点ZustandJotai
状態とロジックの配置1つのストアに集約atom ごとに分散
再レンダリング制御セレクタ (s) => s.count を明示的に書くuseAtomValue(countAtom) で自動分離
派生データストア内で computed を書くか、コンポーネント側で計算atom((get) => ...) で宣言的に定義
イミュータブル更新immer ミドルウェアで mutable 風に書けるスプレッド構文で手動更新(immer 統合も可能)

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

✅ Zustand を選ぶべきケース

ユースケース理由
グローバルな認証・テーマ・設定ストア1ファイルで完結し、React 外からもアクセスしやすい
Redux からの移行概念が近く、ミドルウェア構成も似ている
React 外(WebSocket / Worker)との連携getState() / subscribe() が React に依存しない
チームの学習コストを最小化したいAPI が少なく、ドキュメントを読まなくても使い始められる
バンドルサイズを極限まで削りたい約 1 kB は状態管理ライブラリ最小クラス

✅ Jotai を選ぶべきケース

ユースケース理由
大量のフォームフィールドやセルを持つ UIatom 単位の購読で不要な再レンダリングを自動回避
非同期データの Suspense 統合async atom + <Suspense> がファーストクラスサポート
状態の合成・派生が複雑derived atom のチェーンで宣言的に表現できる
コンポーネントローカルな状態スコープが必要<Provider> でツリーごとに独立した状態空間を作れる
Recoil からの移行atom / selector の概念がほぼ同じで、より軽量

🤝 併用という選択肢

Zustand と Jotai は競合ではなく補完関係にもなり得ます。例えば、認証情報やアプリ設定は Zustand のグローバルストアで管理し、UI の細かいインタラクション状態(モーダルの開閉、フィルタ条件など)は Jotai の atom で管理する、というハイブリッド構成も実用的です。


6. まとめ

Zustand 🐻 = 「ストアを作って、使う」 — シンプル・直感的・React 外 OK
Jotai   👻 = 「atom を組み合わせる」  — 細粒度・宣言的・Suspense 親和性

どちらも **