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 のフレームチャートを見て絶望しました。
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 の学習コスト
ただし、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回目の注入は機能しないことがあります。
解決策:
- Provider を
layout.tsxではなくtemplate.tsxに置く(ルート遷移ごとに再生成される)。 - または、グローバルではなくページレベルの Provider を使用する。
共通の原則(両ライブラリ共通)
Server Components は状態管理を使えない
- Server Components には Hooks がないため、
useStoreやuseAtomは使えません。 - 状態が必要なコンポーネントは
'use client'を付けてください。
- Server Components には Hooks がないため、
Provider の位置
- ツリーの深くに置くほど、Next.js が静的な部分を最適化しやすくなります。
- 一方で、状態を共有する必要がある範囲をカバーできる高さに置く必要もあります。バランスです。
私の選定アドバイス
で、結局どっち?
答えはありませんが、指針(決定木)はあります。
クイック・デシジョンツリー
あなたのプロジェクトが…
シンプルな個人開発 / 小規模アプリ
→ まず 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
全く問題ありません。適材適所でツールを使ってください。
結論
最初の問いに戻りましょう。
Redux は重い? はい、多くのプロジェクトにはオーバーです。
Context は遅い? はい、最適化しないとボトルネックになります。
Zustand か Jotai か?
- 迷ったら Zustand。シンプルで安定的で、失敗が少ない。
- 複雑なら Jotai。依存関係地獄を救ってくれる。
Next.js App Router で使うなら:
- グローバル変数の罠に気をつける(Store Factory パターン)。
- Server Components との境界線を理解する。
最後に一言。
技術選定に「正解」はありません。ネットの記事(この記事も含めて)に流されすぎないでください。一番大事なのは、あなたとあなたのチームが快適に使えて、実際の問題を解決できることです。
迷っているなら、両方で小さなデモを作ってみてください。10分の実践は、10本の記事を読むよりも価値があります。
FAQ
Redux、Context、Zustand、Jotaiの違いは何ですか?
• メリット:強力、エコシステムが豊か、超大規模向き
• デメリット:重い、ボイラープレートが多い、学習コストが高い
Context API:
• メリット:React標準、追加ライブラリ不要
• デメリット:パフォーマンスが低い(不要な再レンダリング)
Zustand:
• メリット:シンプル、コードが少ない、学習コストが低い
• デメリット:超大規模や複雑な依存関係には不向き
• 適用:ほとんどの中小規模プロジェクト
Jotai:
• メリット:原子化状態、細粒度更新、高性能
• デメリット:概念が少し複雑、学習コスト
• 適用:複雑な依存関係、大規模、高性能が要求される場合
Zustand と Jotai、どちらを選ぶべきですか?
• ほとんどのプロジェクト
• シンプルさを重視したい
• チームの学習コストを下げたい
• React 外から状態を操作したい
Jotai を選ぶべきシナリオ:
• 状態間の依存関係が複雑
• リアルタイムアプリなど更新頻度が極めて高い
• Suspense を多用する
• 細粒度な再レンダリング制御が必要
アドバイス:基本は Zustand で始め、必要が生じたら Jotai を検討するのが安全です。
Context API はなぜパフォーマンスが悪いのですか?
Next.js App Router で Zustand を使う方法は?
これを防ぐため、「Store Factory パターン」を使用します:
1. createStore でストアを作成する関数を定義
2. クライアントコンポーネント内で useRef を使ってストアインスタンスを作成
3. React Context (Provider) 経由でそのインスタンスを子コンポーネントに渡す
これにより、リクエストごとに独立したストアが保証されます。
Next.js App Router で Jotai を使う方法は?
1. ルートレイアウト(layout.jsx)またはテンプレート(template.jsx)で <Provider> コンポーネントをラップします。
2. サーバーサイドのデータを初期値として渡す場合は、useHydrateAtoms フックを使用してクライアントサイドでデータを注入します。
7 min read · 公開日: 2025年12月19日 · 更新日: 2026年1月22日
関連記事
Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践

Next.js ファイルアップロード完全ガイド:S3/Qiniu Cloud 署名付き URL 直接アップロード実践
Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド

Next.js Eコマース実践:カートと Stripe 決済の完全実装ガイド
Next.js ユニットテスト実践:Jest + React Testing Library 完全設定ガイド


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