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

Next.js 状態管理選定ガイド: Zustand vs Jotai 実践比較

はじめに

週末の深夜2時、私は画面のエラーメッセージを見つめながら、Redux の action creator を修正するのはこれで17回目でした。プロジェクトはただのカート機能だけなのに、設定ファイルがすでに3つもあります。

その時、ふと疑問が湧きました。「なぜこんなに複雑にしなければならないんだ?」

正直なところ、多くの人が同じジレンマを抱えているはずです。プロジェクトは大きくないから Redux は牛刀割鶏(大げさ)だし、かといって Context API に切り替えると、たった一つの状態更新でページ半分が再レンダリングされてしまう。パフォーマンスパネルの真っ赤な炎を見ると頭が痛くなります。

これが、ここ数年で Zustand と Jotai が爆発的に人気を集めている理由です。彼らが約束するのは「軽量」で「高性能」。しかし、新たな問題も浮上します。「で、結局どっちを選べばいいの?」

正直に言うと、最初は私もよく分かりませんでした。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 内に存在、自動最適化

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

Zustand: 1KB
バンドルサイズ
gzip後。Zustandの方が軽量
Zustand: 10分
学習コスト
Zustandの方が概念がシンプル
Zustand: 手動
パフォーマンス
手動セレクタ vs 自動細粒度更新
Zustand: 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 の学習コスト

ただし、Jotai の概念は Zustand より少し複雑です。

Atom の読み書き、派生 Atom、非同期 Atom、書き込み専用 Atom など、理解すべき概念が多いです。チームに経験が浅いメンバーがいる場合、習得には少し時間がかかるでしょう。

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 を使用する。

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

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

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

    • ツリーの深くに置くほど、Next.js が静的な部分を最適化しやすくなります。
    • 一方で、状態を共有する必要がある範囲をカバーできる高さに置く必要もあります。バランスです。

私の選定アドバイス

で、結局どっち?

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

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

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

  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

全く問題ありません。適材適所でツールを使ってください。

結論

最初の問いに戻りましょう。

Redux は重い? はい、多くのプロジェクトにはオーバーです。
Context は遅い? はい、最適化しないとボトルネックになります。

Zustand か Jotai か?

  • 迷ったら Zustand。シンプルで安定的で、失敗が少ない。
  • 複雑なら Jotai。依存関係地獄を救ってくれる。

Next.js App Router で使うなら:

  • グローバル変数の罠に気をつける(Store Factory パターン)。
  • Server Components との境界線を理解する。

最後に一言。

技術選定に「正解」はありません。ネットの記事(この記事も含めて)に流されすぎないでください。一番大事なのは、あなたとあなたのチームが快適に使えて、実際の問題を解決できることです。

迷っているなら、両方で小さなデモを作ってみてください。10分の実践は、10本の記事を読むよりも価値があります。

FAQ

Redux、Context、Zustand、Jotaiの違いは何ですか?
Redux:
• メリット:強力、エコシステムが豊か、超大規模向き
• デメリット:重い、ボイラープレートが多い、学習コストが高い

Context API:
• メリット:React標準、追加ライブラリ不要
• デメリット:パフォーマンスが低い(不要な再レンダリング)

Zustand:
• メリット:シンプル、コードが少ない、学習コストが低い
• デメリット:超大規模や複雑な依存関係には不向き
• 適用:ほとんどの中小規模プロジェクト

Jotai:
• メリット:原子化状態、細粒度更新、高性能
• デメリット:概念が少し複雑、学習コスト
• 適用:複雑な依存関係、大規模、高性能が要求される場合
Zustand と Jotai、どちらを選ぶべきですか?
Zustand を選ぶべきシナリオ:
• ほとんどのプロジェクト
• シンプルさを重視したい
• チームの学習コストを下げたい
• React 外から状態を操作したい

Jotai を選ぶべきシナリオ:
• 状態間の依存関係が複雑
• リアルタイムアプリなど更新頻度が極めて高い
• Suspense を多用する
• 細粒度な再レンダリング制御が必要

アドバイス:基本は Zustand で始め、必要が生じたら Jotai を検討するのが安全です。
Context API はなぜパフォーマンスが悪いのですか?
Context の設計上、Provider の値が更新されると、その Context を購読しているすべてのコンポーネントが再レンダリングされるためです。たとえオブジェクトの中の使っていないプロパティが変更されただけでも、全体の再レンダリングがトリガーされます。Zustand や Jotai は必要なデータだけを購読する仕組み(セレクタや原子)を持っているため、この問題を回避できます。
Next.js App Router で Zustand を使う方法は?
単にグローバル変数として store を作成すると、サーバーサイドでリクエスト間のデータ漏洩が起きます。
これを防ぐため、「Store Factory パターン」を使用します:
1. createStore でストアを作成する関数を定義
2. クライアントコンポーネント内で useRef を使ってストアインスタンスを作成
3. React Context (Provider) 経由でそのインスタンスを子コンポーネントに渡す
これにより、リクエストごとに独立したストアが保証されます。
Next.js App Router で Jotai を使う方法は?
Jotai も同様に、リクエストごとに独立した Provider が必要です。
1. ルートレイアウト(layout.jsx)またはテンプレート(template.jsx)で <Provider> コンポーネントをラップします。
2. サーバーサイドのデータを初期値として渡す場合は、useHydrateAtoms フックを使用してクライアントサイドでデータを注入します。

7 min read · 公開日: 2025年12月19日 · 更新日: 2026年1月22日

コメント

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

関連記事