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 で可能だがやや冗長) |
| DevTools | Redux 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 との統合が自然です。
- 柔軟な拡張:
atomWithStorage、atomWithQuery(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>
)
}
コード比較のポイント
| 観点 | Zustand | Jotai |
|---|
| 状態とロジックの配置 | 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 を選ぶべきケース
| ユースケース | 理由 |
|---|
| 大量のフォームフィールドやセルを持つ UI | atom 単位の購読で不要な再レンダリングを自動回避 |
| 非同期データの Suspense 統合 | async atom + <Suspense> がファーストクラスサポート |
| 状態の合成・派生が複雑 | derived atom のチェーンで宣言的に表現できる |
| コンポーネントローカルな状態スコープが必要 | <Provider> でツリーごとに独立した状態空間を作れる |
| Recoil からの移行 | atom / selector の概念がほぼ同じで、より軽量 |
🤝 併用という選択肢
Zustand と Jotai は競合ではなく補完関係にもなり得ます。例えば、認証情報やアプリ設定は Zustand のグローバルストアで管理し、UI の細かいインタラクション状態(モーダルの開閉、フィルタ条件など)は Jotai の atom で管理する、というハイブリッド構成も実用的です。
6. まとめ
Zustand 🐻 = 「ストアを作って、使う」 — シンプル・直感的・React 外 OK
Jotai 👻 = 「atom を組み合わせる」 — 細粒度・宣言的・Suspense 親和性
どちらも **