Next.js 状態管理選定ガイド: Zustand vs Jotai 実践比較
またエラーが発生しました。Redux の action creator を修正するのは、これで 17 回目です。ショッピングカート機能が一つあるだけのプロジェクトなのに、設定ファイルはすでに 3 つも存在しています。
なぜこれほど複雑にする必要があるのでしょうか。規模の小さいプロジェクトで Redux を使うのはオーバースペックですし、かといって Context API に切り替えると、一つの状態更新でページ全体の半分が再レンダリングされてしまいます。パフォーマンスパネルの真っ赤な表示は、見ているだけで頭が痛くなります。
こうした背景から、ここ数年で Zustand と Jotai が急速に注目を集めるようになりました。どちらも「軽量」かつ「高性能」を掲げていますが、実際に選ぶとなると迷う部分も多いはずです。そこで本記事では、以下のテーマについて解説します。
- Redux と Context が使いにくい理由(実際の落とし穴)
- Zustand と Jotai の本質的な違い
- どのシナリオでどちらを選ぶべきか(選定の決定木)
- Next.js App Router での具体的な使い方と注意点
なぜ Redux や Context ではダメなのか?
Redux の「重さ」とは何か?
まず Redux ですが、決して悪いわけではありません。ただ、多くのプロジェクトにとっては「やりすぎ」なのです。
action types を書き、action creators を書き、reducers を書き、さらに store を設定する。「カートに追加」という単純な機能のために、3つも4つもファイルを触る必要があります。このボイラープレート(定型コード)の量は、書いていると本当にうんざりしてきます。
さらに重要なのは、チームに新人がいる場合、Redux の学習曲線はかなり急だということです。dispatch とは何か? なぜ純粋関数(pure function)でなければならないのか? ミドルウェアは何をしているのか? これらの概念を理解するのに時間がかかります。
ToDo リストや個人ブログのような小規模プロジェクトにとって、Redux は「戦車でスーパーに買い物に行く」ようなものです。行けるけど、その必要はありません。
Context API のパフォーマンスの罠
では Context はどうでしょう? 確かにシンプルで、React 公式機能なのでライブラリのインストールも不要です。
しかし、大きな問題があります。「パフォーマンス」です。
Context の仕組み上、Provider の value が変わると、その Context を消費しているすべてのコンポーネントが再レンダリングされます。たとえ、その中のたった一つのフィールドしか使っていなくても、コンポーネント全体が再レンダリング・プロセスに巻き込まれます。
以前、フォームの各フィールドの状態管理に Context を使ったことがありました。結果、1つの入力欄の onChange が発火するたびに、ページ上の20個のコンポーネント全部が再レンダリングされました。Chrome DevTools のフレームチャートを見て絶望しました。
memo や useMemo を使ったり、Context を分割したりして最適化することは可能ですが、正直なところ、そこまでして最適化したコードは、もう Redux より簡単とは言えません。
軽量ソリューションの魅力
だからこそ、Zustand と Jotai がこれほど支持されているのです。
彼らの約束は明確です:
- API がシンプルで、すぐに覚えられる(Zustand なら10分)
- パフォーマンス最適化が組み込まれており、手動で頑張る必要がない
- バンドルサイズが小さい(Zustand はわずか 1KB)
データもこれを裏付けています。2025年の統計によると、Zustand の使用量は過去1年で150%増加しました。ますます多くの開発者が Redux を離れ、これらの軽量ソリューションに移行しています。
しかし、ここで新たな疑問が。「Zustand と Jotai、どっち?」
Zustand vs Jotai コア比較
この2つのライブラリは、表面上はどちらも「軽量状態管理」ですが、底流にある設計思想は全く異なります。
状態モデル:巨大なデパート vs 屋台の集まり
公式ドキュメントにある言葉が、すべてを物語っています。「Zustand is like Redux. Jotai is like Recoil.」
Zustand は本質的に「簡略化された Redux」です。1つの大きな store があり、すべての状態はその中に入っています。巨大なデパートのように、すべてが中央集権的に管理されます。
// Zustand: 1つの大きな store
const useStore = create((set) => ({
user: null,
cart: [],
theme: 'light',
// すべての状態がここに
}))
一方、Jotai は「原子的(Atomic)」です。各状態は独立した atom であり、それぞれが独立した屋台のようです。
// Jotai: 独立した atoms
const userAtom = atom(null)
const cartAtom = atom([])
const themeAtom = atom('light')
この設計の違いが、それぞれに適したシナリオを決定づけます。
保存場所:モジュール外 vs コンポーネントツリー内
Zustand の store はモジュールレベルで存在し、React の外側にあります。Provider なしで、どこからでもインポートして更新できます。
Jotai の atoms はコンポーネントツリー内に存在し(概念的に)、Context に依存します。ルートコンポーネントを Provider でラップする必要があり、状態はコンポーネント間で共有されます。
これが何を意味するか?
もし React コンポーネントの外(ユーティリティ関数や WebSocket コールバックなど)で状態を更新したいなら、Zustand の方が圧倒的に便利です。Jotai でも不可能ではありませんが、少し工夫が必要です。
パフォーマンス特性:手動最適化 vs 自動最適化
パフォーマンス戦略も異なります。
Jotai の原子モデルによるサブスクリプションは、デフォルトで最適化されています。コンポーネントは自分が使用している atoms だけを購読するため、無関係な atoms が更新されても再レンダリングされません。
Zustand は selector を使って手動で最適化する必要があります:
// 非推奨:store 全体を購読
const store = useStore()
// 推奨:selector を使って必要な部分だけ購読
const user = useStore(state => state.user)
とはいえ、Zustand の selector も書くのは簡単ですし、直感的です。ただ、書き忘れると無駄なレンダリングが発生する可能性があります。
一言まとめ
- Zustand: 単一ストア、React 外に存在、手動セレクタ
- Jotai: 原子的 atoms、React 内に存在、自動最適化
どちらを選ぶかは、プロジェクトの特性次第です。
どんな時に Zustand を選ぶべきか?
まず Zustand から。以下の特徴に当てはまるなら、Zustand がほぼ間違いなくベストチョイスです。
中小規模アプリで、複雑にしたくない場合
正直なところ、ほとんどのプロジェクトに複雑な状態管理は不要です。
ECサイトで管理するグローバル状態といえば、ユーザー情報、カートの中身、テーマ設定くらいです。この規模なら Zustand が最適です。
API は学習不要なほどシンプルです。完全な例を見てみましょう:
// store.js
import create from 'zustand'
const useStore = create((set) => ({
cart: [],
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
removeFromCart: (id) => set((state) => ({
cart: state.cart.filter(item => item.id !== id)
})),
}))
// CartButton.jsx
function CartButton() {
const addToCart = useStore(state => state.addToCart)
return <button onClick={() => addToCart(item)}>カートに追加</button>
}
// CartCount.jsx
function CartCount() {
const count = useStore(state => state.cart.length)
return <span>{count}</span>
}
見ましたか? Provider も action types も reducer もありません。状態と更新メソッドを定義して、使うだけ。
チームに新人が入っても、これなら10分で理解できます。
React の外で状態を更新したい場合
これは Zustand のユニークな強みです。
例えば WebSocket 接続で、メッセージを受信した時に状態を更新したいとします:
// websocket.js
import { useStore } from './store'
socket.on('message', (data) => {
// コンポーネント外から直接 store メソッドを呼べる
useStore.getState().updateMessages(data)
})
あるいは、ユーティリティ関数内で現在の状態を確認したい場合:
// utils.js
import { useStore } from './store'
export function checkPermission() {
const user = useStore.getState().user
return user?.role === 'admin'
}
Jotai では atoms がコンポーネントツリーにバインドされているため、これを行うのは面倒です。
Next.js SSR フレンドリー
Zustand は Next.js との親和性が非常に高いです。
公式ドキュメントには Next.js 統合専用の章があり、App Router のベストプラクティスも提供されています。コミュニティでの知見も多く、問題にぶつかっても解決策が見つかりやすいです。
Next.js 13+ の App Router を使っているなら、Zustand は現在最も安定した選択肢の一つです(設定方法は後述)。
どんな時に Zustand は不向きか?
しかし、1つだけ苦手なシナリオがあります。「状態間に複雑な派生関係がある場合」です。
例えばフィルタ機能で、10個の条件があり、各条件の選択肢が他の条件の値に依存している…といった場合、Zustand だとコードがスパゲッティになりがちです。
そこで Jotai の出番です。
どんな時に Jotai を選ぶべきか?
Jotai のアトミックデザインは、特定のシナリオでは神がかって便利です。
複雑な状態依存関係がある場合
これこそ Jotai の得意分野です。
商品フィルターを作るとしましょう:
- 「ブランド」「価格帯」「評価」などの条件がある
- 選択可能なブランドリストは、現在の価格帯に依存する
- 最終的な商品リストは、すべての条件に依存する
Zustand で書くと、これらの依存関係を手動で管理しなければならず、カオスになります。
Jotai なら、こう書けます:
// 基礎 atoms
const brandAtom = atom([])
const priceRangeAtom = atom([0, 1000])
const ratingAtom = atom(0)
// 派生 atom:選択可能なブランド(価格帯に依存)
const availableBrandsAtom = atom((get) => {
const priceRange = get(priceRangeAtom)
return fetchBrands(priceRange) // priceRange が変われば自動再計算
})
// 派生 atom:フィルタ後の商品(全条件に依存)
const filteredProductsAtom = atom((get) => {
const brands = get(brandAtom)
const priceRange = get(priceRangeAtom)
const rating = get(ratingAtom)
return products.filter(/* フィルタロジック */)
})
分かりますか? 各 atom は自分が依存する他の atoms のことだけを気にすればいいのです。Jotai が依存関係を自動追跡し、どれか一つが変われば、関連する atoms だけを自動更新します。
コンポーネントでの使用もシンプルです:
function FilterPanel() {
const [brands, setBrands] = useAtom(brandAtom)
const availableBrands = useAtomValue(availableBrandsAtom)
// brands が変われば availableBrands も自動更新
}
このシナリオでは、Jotai の方が圧倒的にコードが綺麗になります。
極限のパフォーマンスが求められる場合
Jotai のアトミックなサブスクリプション機構は、本当に高速です。
リアルタイムのデータダッシュボードで、画面上に50個のコンポーネントがあり、それぞれ異なる指標を表示しているとします。Context や最適化されていない Zustand だと、1つのデータ更新で多数のコンポーネントが再レンダリングされるリスクがあります。
Jotai ならその心配はありません。各コンポーネントは自分の atom だけを購読しているので、他の atoms が更新されても全く影響を受けません。
公式ドキュメントが “This is the most performant by default.” と謳う通りです。
コード分割が必要な大規模アプリ
Jotai の atoms はオンデマンドでロードできます。
atoms を別々のファイルに分散させ、使う時だけインポートすることが可能です。これは大規模アプリの初期ロード時間短縮に役立ちます。
Zustand の store は通常ひとかたまりのオブジェクトなので、分割は可能ですが Jotai ほど自然ではありません。
Suspense を多用するプロジェクト
React Suspense(非同期データ読み込みなど)を多用する場合、Jotai はネイティブサポートしています。
非同期 atom は直感的に書けます:
const userAtom = atom(async () => {
const res = await fetch('/api/user')
return res.json()
})
function UserProfile() {
const user = useAtomValue(userAtom)
// データロードが完了するまで自動的に Suspense が発動
return <div>{user.name}</div>
}
Zustand も Suspense と連携できますが、Jotai ほどのシームレスさはありません。
Jotai の学習コスト
Atom の読み書き、派生 atom、非同期 atom、これらの概念を理解するには少し時間がかかります。チームに経験が浅いメンバーがいる場合、習得速度は Zustand より遅くなるでしょう。
また Jotai のドキュメントは、正直 Zustand ほど親切ではありません。各 API の使い方を理解するには、何度か読み返す必要があるかもしれません。
Next.js App Router ベストプラクティス
理論はこれくらいにして、実践の話をしましょう。Next.js 13+ の App Router でこれらを使う際の、絶対に知っておくべき落とし穴があります。
Zustand × Next.js の正解
大原則:グローバル store は使うな
多くの人(初期の私も含む)がやってしまう間違い:
// ❌ 間違い:グローバル store
import create from 'zustand'
const useStore = create((set) => ({
user: null,
setUser: (user) => set({ user })
}))
クライアントサイドレンダリング(SPA)ならこれで問題ありません。しかし Next.js の SSR 環境では、この store インスタンスはサーバー上で複数のリクエスト間で共有されてしまいます。つまり、ユーザーAのデータがユーザーBに見えてしまうというセキュリティリスクがあります。
公式推奨の方法は Store Factory パターン です:
// lib/store.js
import { createStore } from 'zustand/vanilla'
export function createUserStore(initialState) {
return createStore((set) => ({
user: initialState?.user || null,
setUser: (user) => set({ user })
}))
}
そしてクライアントコンポーネントで Provider を作成します:
// components/StoreProvider.jsx
'use client'
import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { createUserStore } from '@/lib/store'
const StoreContext = createContext(null)
export function StoreProvider({ children, initialState }) {
const storeRef = useRef()
if (!storeRef.current) {
storeRef.current = createUserStore(initialState)
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
export function useUserStore(selector) {
const store = useContext(StoreContext)
return useStore(store, selector)
}
これをルートレイアウトで使用します:
// app/layout.jsx
import { StoreProvider } from '@/components/StoreProvider'
export default function RootLayout({ children }) {
// 必要ならここで初期データを取得
const initialState = { user: null }
return (
<html>
<body>
<StoreProvider initialState={initialState}>
{children}
</StoreProvider>
</body>
</html>
)
}
これで、リクエストごとに独立した store が生成され、データ混入のリスクがなくなります。
注意点:
- Server Components からは store を直接読み書きできません。
- データプリフェッチはサーバー側で行い、
initialState経由でクライアントに渡します。 - ルートレイアウトでのデータ取得はブロッキングになるため、パフォーマンスに注意してください。
Jotai × Next.js の SSR Hydration 問題
Jotai を Next.js で使う時の最大の敵は Hydration エラーです。
リクエストごとに独立した Provider が必要:
// app/providers.jsx
'use client'
import { Provider } from 'jotai'
export function Providers({ children }) {
return <Provider>{children}</Provider>
}
// app/layout.jsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
サーバーデータの注入:
サーバー側のデータを atoms に注入したい場合、useHydrateAtoms を使います:
'use client'
import { useHydrateAtoms } from 'jotai/utils'
import { userAtom } from '@/atoms'
export function HydrateAtoms({ initialUser, children }) {
useHydrateAtoms([[userAtom, initialUser]])
return children
}
しかしここに罠があります:useHydrateAtoms は初回レンダリング時のみ有効です。App Router で router.push でページ遷移した場合、同じ atom への2回目の注入は機能しないことがあります。
解決策:
- Provider を
layout.tsxではなくtemplate.tsxに置く(ルート遷移ごとに再生成される)。 - または、グローバルではなくページレベルの Provider を使用する。
atomWithStorage の hydration エラー:
atomWithStorage でフォームデータを保存すると、サーバーとクライアントの hydration 不一致が起きることがあります:
- サーバー側:フォームは空
- クライアント側:localStorage から読み込み、フォームに値がある
- 結果:React hydration mismatch エラー
解決策:useEffect 内でフォームを埋める、または useHydrateAtoms + useSyncExternalStore を使う。
共通の原則(両ライブラリ共通)
-
Server Components は状態管理を使えない
- Server Components には Hooks がないため、
useStoreやuseAtomは使えません。 - 状態が必要なコンポーネントは
'use client'を付けてください。
- Server Components には Hooks がないため、
-
Provider の位置
- ツリーの深くに置くほど、Next.js が静的な部分を最適化しやすくなります。
- 一方で、状態を共有する必要がある範囲をカバーできる高さに置く必要もあります。バランスです。
-
ルートレイアウトのブロッキングを避ける
- root layout で
await fetch()してユーザーデータを取得しない - streaming と Server Components のパフォーマンスメリットが失われる
- 専用の Client Component でデータ取得と初期化を行う
- root layout で
これらの落とし穴、私も全部踏みました。この原則を覚えておけば、デバッグ時間を大幅に節約できます。
私の選定アドバイス
で、結局どっち?
答えはありませんが、指針(決定木)はあります。
クイック・デシジョンツリー
あなたのプロジェクトが…
-
シンプルな個人開発 / 小規模アプリ
→ まず Context API。それで十分なら変えない。
→ パフォーマンスがきつくなったら Zustand。 -
中規模 SaaS / ECサイト
→ いきなり Zustand。
→ シンプル、安定、チーム全員がすぐ理解できる。 -
複雑なデータダッシュボード / リアルタイムアプリ
→ Jotai を検討。
→ 状態の依存関係が複雑なら、Jotai の方が圧倒的に楽。 -
大規模チーム / 厳格な規約が必要
→ Redux Toolkit の方が合っているかも。
→ 制約とベストプラクティスが強制されるため。 -
React 外での状態更新が頻繁
→ Zustand 一択。
→ Jotai はここが弱い。 -
Suspense を使い倒したい
→ Jotai がスムーズ。
段階的戦略
私のおすすめは「段階的導入」です:
-
フェーズ1: Context API
- 初期段階。状態も少ない。Context で十分。過剰な最適化はしない。
-
フェーズ2: Zustand
- Context の再レンダリングが気になり始めた。
- グローバル状態が増えてきた。
- ここで Zustand を導入。80%の悩みはこれで解決。
-
フェーズ3: Jotai(または Zustand 維持)
- 状態間の依存関係が複雑すぎて Zustand だと辛い。
- このレベルになって初めて Jotai を検討。
- 無理に技術スタックを増やす必要はない。
混在はあり?
ありです!
実際に見たことがある構成:
- グローバル設定(ユーザー、テーマ)は Zustand
- 複雑なフォーム状態管理は Jotai
全く問題ありません。問題に最適なツールを選びましょう。
私の実践経験
自分の選択を共有します:
- 個人ブログ:状態管理なし。Server Components + URL state で十分
- 管理画面プロジェクト:Zustand。サーバー状態は React Query と組み合わせ
- リアルタイムデータダッシュボード:Jotai。状態依存関係が複雑で、Zustand だとコードがごちゃごちゃになる
ポイントは、最初から技術選定に悩みすぎないこと。まず最もシンプルなアプローチから始め、問題が出たらアップグレードする。
多くのプロジェクトでは Context API で十分です。過小評価しないでください。
結論
最初の問いに戻りましょう。
Redux は重い? はい、多くのプロジェクトにはオーバーです。
Context は遅い? はい、最適化しないとボトルネックになります。
Zustand か Jotai か?
- 迷ったら Zustand。シンプルで安定的で、失敗が少ない。
- 複雑なら Jotai。依存関係地獄を救ってくれる。
Next.js App Router で使うなら、次の 3 点を覚えておいてください:
- グローバル store を使わない
- リクエストごとに独立した Provider
- Server Components では状態管理に触らない
最後に一言。
技術選定に「正解」はありません。ネットの記事(この記事も含めて)に流されすぎないでください。一番大事なのは、あなたとあなたのチームが快適に使えて、実際の問題を解決できることです。
今迷っているなら、どちらか選んでデモを書いてみてください。10 分の実践は、10 記事読むより価値があります。
適切なツールが見つかることを願っています。
FAQ
Redux、Context、Zustand、Jotai の違いは?
• メリット:機能が強力、エコシステムが豊富、超大規模プロジェクト向き
• デメリット:重い、ボイラープレートが多い、学習コストが高い
• 適用:超大規模プロジェクト、複雑な状態管理が必要な場合
Context API:
• メリット:React 標準、追加ライブラリ不要
• デメリット:パフォーマンスが低い、1 つの状態更新でサブツリー全体が再レンダリングされる
• 適用:シンプルなグローバル状態、更新頻度が低い場合
Zustand:
• メリット:シンプルで直接的、コード量が少ない、学習コストが低い
• デメリット:超大規模プロジェクトには不向き
• 適用:ほとんどのプロジェクト、中小規模プロジェクト
Jotai:
• メリット:原子化状態、細粒度更新、パフォーマンスが高い
• デメリット:学習コストが高い、コードが複雑になりやすい
• 適用:大規模プロジェクト、極限のパフォーマンスが必要な場合
選択の提案:ほとんどのプロジェクトは Zustand、大規模または極限のパフォーマンスが必要なら Jotai。
Zustand と Jotai、どちらを選ぶべき?
• ほとんどのプロジェクト
• 中小規模プロジェクト
• シンプルで直接的な状態管理が必要
• チームの学習コストを抑えたい
• コード量を少なくしたい
Jotai を選ぶシーン:
• 大規模プロジェクト
• 極限のパフォーマンスが必要
• 状態更新が頻繁
• 細粒度更新が必要
• チームに経験がある
決定木:
• プロジェクト規模が小さい → Zustand
• プロジェクト規模が大きい → Jotai
• パフォーマンス要件が高い → Jotai
• シンプルさ重視 → Zustand
提案:ほとんどのプロジェクトは Zustand。大規模または極限のパフォーマンスが必要な場合のみ Jotai。
Context API はなぜパフォーマンスが悪い?
解決策:
• Context を分割する(機能ごとに別 Context)
• useMemo と memo で最適化
• Zustand または Jotai に切り替える(細粒度サブスクリプション対応)
提案:状態更新が頻繁な場合、Context API は非推奨。Zustand または Jotai を使いましょう。
Next.js App Router で Zustand をどう使う?
1. createStore で store ファクトリ関数を作成
2. クライアントコンポーネントで useRef を使って store インスタンスを作成
3. Context Provider 経由で子コンポーネントにインスタンスを渡す
ポイント:
• store を使うコンポーネントは 'use client' が必須
• Server Components では状態管理は使えない
• リクエストごとに独立した store インスタンスを用意し、データ混入を防ぐ
これにより各リクエストが独立した状態を保ちつつ、Zustand のシンプルさを維持できます。
Next.js App Router で Jotai をどう使う?
1. ルートレイアウトまたは template.jsx で Provider コンポーネントをラップ
2. useHydrateAtoms でサーバー側データを初期値として注入
3. 注意:useHydrateAtoms は初回レンダリング時のみ有効
ポイント:
• atom を使うコンポーネントは Client Component であること
• Server Components では状態管理は使えない
• 原子化設計により細粒度更新が自動実現
メリット:
• 特定 atom を購読するコンポーネントだけが更新される
• Suspense と非同期データをネイティブサポート
• 複雑な状態依存と大規模プロジェクトに適する
注意:Jotai の学習コストは Zustand より高いが、複雑なシーンではパフォーマンスとコードの明瞭さに優れる。
Server Components で状態管理は使える?
正しいやり方:
• Server Components でデータ取得(fetch、DB クエリなど)
• props で Client Components にデータを渡す
• Client Components で状態管理とユーザー操作を担当
アーキテクチャパターン:
Server Component が初期データ取得 → props で渡す → Client Component が Zustand/Jotai で状態と操作を管理
この分担により、サーバー側はデータ取得と SEO に集中し、クライアント側は操作と状態管理に集中できます。Next.js App Router の強みを活かせます。
7分で読めます · 公開日: 2025年12月19日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js キャッシュメカニズム完全ガイド:revalidate の正しい使用タイミングを習得する
Next.js の4層キャッシュメカニズムを深く解析し、revalidate、revalidatePath、revalidateTag の使用タイミングを理解します。データが更新されない等の一般的な問題を解決し、完全なトラブルシューティングガイドとベストプラクティスを提供します。
第 25 / 47 記事
次の記事
Next.js TypeScript 設定の応用:tsconfig 最適化と型安全の実践
Next.js の TypeScript 設定を深掘り。tsconfig の strict モード、型安全ルーティング、環境変数の型定義まで解説し、any を減らして開発体験を上げる実践ガイドです。
第 27 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます