言語を切り替える
テーマを切り替える

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 のフレームチャートを見て絶望しました。

memouseMemo を使ったり、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 内に存在、自動最適化

どちらを選ぶかは、プロジェクトの特性次第です。

1KB
バンドルサイズ
gzip 後のサイズ。Zustand の方が軽量
10 分
学習コスト
習得までの時間。Zustand の方がシンプル
手動
パフォーマンス最適化
手動 selector vs 自動細粒度更新
80%
適用シーン
ほとんどのプロジェクトは Zustand、複雑なシーンは Jotai

どんな時に 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回目の注入は機能しないことがあります。

解決策:

  1. Provider を layout.tsx ではなく template.tsx に置く(ルート遷移ごとに再生成される)。
  2. または、グローバルではなくページレベルの Provider を使用する。

atomWithStorage の hydration エラー

atomWithStorage でフォームデータを保存すると、サーバーとクライアントの hydration 不一致が起きることがあります:

  • サーバー側:フォームは空
  • クライアント側:localStorage から読み込み、フォームに値がある
  • 結果:React hydration mismatch エラー

解決策:useEffect 内でフォームを埋める、または useHydrateAtoms + useSyncExternalStore を使う。

共通の原則(両ライブラリ共通)

  1. Server Components は状態管理を使えない

    • Server Components には Hooks がないため、useStoreuseAtom は使えません。
    • 状態が必要なコンポーネントは 'use client' を付けてください。
  2. Provider の位置

    • ツリーの深くに置くほど、Next.js が静的な部分を最適化しやすくなります。
    • 一方で、状態を共有する必要がある範囲をカバーできる高さに置く必要もあります。バランスです。
  3. ルートレイアウトのブロッキングを避ける

    • root layout で await fetch() してユーザーデータを取得しない
    • streaming と Server Components のパフォーマンスメリットが失われる
    • 専用の Client Component でデータ取得と初期化を行う

これらの落とし穴、私も全部踏みました。この原則を覚えておけば、デバッグ時間を大幅に節約できます。

私の選定アドバイス

で、結局どっち?

答えはありませんが、指針(決定木)はあります。

クイック・デシジョンツリー

あなたのプロジェクトが…

  1. シンプルな個人開発 / 小規模アプリ
    → まず Context API。それで十分なら変えない。
    → パフォーマンスがきつくなったら Zustand。

  2. 中規模 SaaS / ECサイト
    → いきなり Zustand
    → シンプル、安定、チーム全員がすぐ理解できる。

  3. 複雑なデータダッシュボード / リアルタイムアプリ
    Jotai を検討。
    → 状態の依存関係が複雑なら、Jotai の方が圧倒的に楽。

  4. 大規模チーム / 厳格な規約が必要
    → Redux Toolkit の方が合っているかも。
    → 制約とベストプラクティスが強制されるため。

  5. React 外での状態更新が頻繁
    Zustand 一択。
    → Jotai はここが弱い。

  6. Suspense を使い倒したい
    Jotai がスムーズ。

段階的戦略

私のおすすめは「段階的導入」です:

  1. フェーズ1: Context API

    • 初期段階。状態も少ない。Context で十分。過剰な最適化はしない。
  2. フェーズ2: Zustand

    • Context の再レンダリングが気になり始めた。
    • グローバル状態が増えてきた。
    • ここで Zustand を導入。80%の悩みはこれで解決。
  3. フェーズ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 の違いは?
Redux:
• メリット:機能が強力、エコシステムが豊富、超大規模プロジェクト向き
• デメリット:重い、ボイラープレートが多い、学習コストが高い
• 適用:超大規模プロジェクト、複雑な状態管理が必要な場合

Context API:
• メリット:React 標準、追加ライブラリ不要
• デメリット:パフォーマンスが低い、1 つの状態更新でサブツリー全体が再レンダリングされる
• 適用:シンプルなグローバル状態、更新頻度が低い場合

Zustand:
• メリット:シンプルで直接的、コード量が少ない、学習コストが低い
• デメリット:超大規模プロジェクトには不向き
• 適用:ほとんどのプロジェクト、中小規模プロジェクト

Jotai:
• メリット:原子化状態、細粒度更新、パフォーマンスが高い
• デメリット:学習コストが高い、コードが複雑になりやすい
• 適用:大規模プロジェクト、極限のパフォーマンスが必要な場合

選択の提案:ほとんどのプロジェクトは Zustand、大規模または極限のパフォーマンスが必要なら Jotai。
Zustand と Jotai、どちらを選ぶべき?
Zustand を選ぶシーン:
• ほとんどのプロジェクト
• 中小規模プロジェクト
• シンプルで直接的な状態管理が必要
• チームの学習コストを抑えたい
• コード量を少なくしたい

Jotai を選ぶシーン:
• 大規模プロジェクト
• 極限のパフォーマンスが必要
• 状態更新が頻繁
• 細粒度更新が必要
• チームに経験がある

決定木:
• プロジェクト規模が小さい → Zustand
• プロジェクト規模が大きい → Jotai
• パフォーマンス要件が高い → Jotai
• シンプルさ重視 → Zustand

提案:ほとんどのプロジェクトは Zustand。大規模または極限のパフォーマンスが必要な場合のみ Jotai。
Context API はなぜパフォーマンスが悪い?
Context の設計上、Provider の値が更新されると、その Context を購読しているすべてのコンポーネントが再レンダリングされます。オブジェクトの 1 フィールドだけ更新しても、すべてのコンシューマーが再レンダリングをトリガーします。

解決策:
• Context を分割する(機能ごとに別 Context)
• useMemo と memo で最適化
• Zustand または Jotai に切り替える(細粒度サブスクリプション対応)

提案:状態更新が頻繁な場合、Context API は非推奨。Zustand または Jotai を使いましょう。
Next.js App Router で Zustand をどう使う?
Next.js App Router で Zustand を使う際は、グローバル store によるサーバー側データ漏洩を避ける必要があります。Store Factory パターンを推奨します:

1. createStore で store ファクトリ関数を作成
2. クライアントコンポーネントで useRef を使って store インスタンスを作成
3. Context Provider 経由で子コンポーネントにインスタンスを渡す

ポイント:
• store を使うコンポーネントは 'use client' が必須
• Server Components では状態管理は使えない
• リクエストごとに独立した store インスタンスを用意し、データ混入を防ぐ

これにより各リクエストが独立した状態を保ちつつ、Zustand のシンプルさを維持できます。
Next.js App Router で Jotai をどう使う?
Jotai を Next.js App Router で使う場合、リクエストごとに独立した Provider を作成する必要があります:

1. ルートレイアウトまたは template.jsx で Provider コンポーネントをラップ
2. useHydrateAtoms でサーバー側データを初期値として注入
3. 注意:useHydrateAtoms は初回レンダリング時のみ有効

ポイント:
• atom を使うコンポーネントは Client Component であること
• Server Components では状態管理は使えない
• 原子化設計により細粒度更新が自動実現

メリット:
• 特定 atom を購読するコンポーネントだけが更新される
• Suspense と非同期データをネイティブサポート
• 複雑な状態依存と大規模プロジェクトに適する

注意:Jotai の学習コストは Zustand より高いが、複雑なシーンではパフォーマンスとコードの明瞭さに優れる。
Server Components で状態管理は使える?
使えません。Server Components はサーバー側でレンダリングされ、クライアント状態がないため、状態管理ライブラリや React hooks は使えません。

正しいやり方:
• 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日

関連記事

コメント

GitHubアカウントでログインしてコメントできます