Next.jsのApp Routerに移行して、状態管理のベストプラクティスが分からず困っていませんか? 従来のPages Routerと同じ感覚で状態管理ライブラリを導入すると、「Server Componentsで使えない」「ハイドレーションエラーが発生する」といった落とし穴にハマりがちです。
この記事では、Next.js App Router環境下で、主要な軽量状態管理ライブラリであるZustand, Jotai, Recoilの特性を徹底比較し、RSC(React Server Components)との相性、パフォーマンス、実装の容易さを検証します。この記事を読めば、あなたのプロジェクトに最適なNext.js 状態管理ライブラリを選定し、App Routerの恩恵を最大限に引き出す実装パターンを習得できます。
Next.js App Routerと状態管理の基本
このセクションでは、Next.js App Routerにおける状態管理の基本的な考え方と、Server Components(RSC)の制約について解説します。
App Routerの登場とServer Componentsの制約
Next.js 13.4でApp Routerが安定版となり、React Server Components (RSC) を活用した新しいアプリケーション開発のパラダイムが導入されました。RSCはサーバーサイドでレンダリングされ、クライアントに送られるJavaScriptの量を減らすことで、初期ロードパフォーマンスを劇的に向上させます。
しかし、RSCには重要な制約があります。Server Componentsはフック(useState, useContextなど)を使用できず、ステートフルではありません (Next.js公式ドキュメント - Server Components)。これは、Zustand、Jotai、Recoilといったクライアントサイドの状態管理ライブラリをServer Componentsで直接利用できないことを意味します。
Client Componentsとの使い分け
App Routerでは、デフォルトでServer Componentsとして動作します。インタラクションや状態管理が必要なコンポーネントのみ、ファイルの先頭に"use client";ディレクティブを記述してClient Componentsとして明示的に指定する必要があります。この「アイランドアーキテクチャ」の考え方が、App Routerにおける状態管理の鍵となります。
状態管理ライブラリはすべてClient Components内で使用することを前提とします。Server Componentsの役割は、可能な限り多くのデータをフェッチし、静的なUIをレンダリングすることに集中させるのがベストプラクティスです。
主要な軽量状態管理ライブラリの比較
ここでは、Zustand、Jotai、Recoilそれぞれの特徴、App Routerでの導入方法、実装例を具体的に比較します。
Zustand: シンプルさと手軽さが魅力
Zustandは、Reduxに代わる軽量な状態管理ライブラリとして人気を集めています。ボイラープレートが少なく、学習コストが低いのが特徴です。
特徴とメリット
-
プロバイダー不要:
create関数でストアを作成し、フックとしてコンポーネントで直接利用できます。Providerコンポーネントでラップする必要がないため、非常に手軽です。 - シンプルで直感的: APIがシンプルで、状態の定義と更新が分かりやすいです。
- 軽量: バンドルサイズが非常に小さいです。
- SSR対応: Next.jsのSSR環境におけるストアの初期化も比較的容易です (Zustand公式ドキュメント - SSR)。
Next.js App RouterでのZustand実装例
-
インストール:
npm install zustand -
ストアの作成 (
store/useCounterStore.ts):
Client Componentsで利用するため、"use client";はストア自体ではなく、ストアを利用するコンポーネントに記述します。import { create } from 'zustand'; interface CounterState { count: number; increment: () => void; decrement: () => void; } export const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), })); -
クライアントコンポーネントでの利用 (
app/components/Counter.tsx):"use client"; // クライアントコンポーネントであることを明示 import { useCounterStore } from '@/store/useCounterStore'; export default function Counter() { const { count, increment, decrement } = useCounterStore(); return ( <div> <p>Count: {count}</p> <button onClick={increment}>Increment</button> <button onClick={decrement}>Decrement</button> </div> ); } -
ページでの利用 (
app/page.tsx):
Server ComponentsであるページからClient Componentsをインポートして利用します。import Counter from './components/Counter'; export default function HomePage() { return ( <main> <h1>Zustand Counter Example</h1> <Counter /> </main> ); }
Jotai: アトミックな状態管理でパフォーマンス最適化
Jotaiは、Reactの状態管理にアトミックなアプローチを採用しています。各状態(アトム)が独立して存在し、必要な部分だけを再レンダリングするため、高いパフォーマンスを発揮します。
特徴とメリット
- アトミックな設計: 状態を最小単位のアトムに分割し、それぞれが独立して更新されます。これにより、不必要な再レンダリングを最小限に抑えられます。
-
フックベース: Reactの
useStateに近い感覚でアトムを扱えます。 - 高いパフォーマンス: 細粒度な更新により、大規模なアプリケーションでも高いパフォーマンスが期待できます。
-
派生状態: 他のアトムから派生した状態(
selectorに相当)も容易に定義できます。
Next.js App RouterでのJotai実装例
JotaiはProviderを必要とします。App Routerでは、このProviderをルートレイアウトに配置する必要があります。
-
インストール:
npm install jotai -
Providerの追加 (
app/layout.tsx):
Provider自体は"use client";を必要としませんが、内部でuseAtomなどを使うコンポーネントは"use client";を付ける必要があります。import { Provider } from 'jotai'; import './globals.css'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <Provider> {children} </Provider> </body> </html> ); } -
アトムの作成 (
store/counterAtom.ts):import { atom } from 'jotai'; export const countAtom = atom(0); -
クライアントコンポーネントでの利用 (
app/components/JotaiCounter.tsx):"use client"; import { useAtom } from 'jotai'; import { countAtom } from '@/store/counterAtom'; export default function JotaiCounter() { const [count, setCount] = useAtom(countAtom); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(c => c + 1)}>Increment</button> <button onClick={() => setCount(c => c - 1)}>Decrement</button> </div> ); } -
ページでの利用 (
app/page.tsx):import JotaiCounter from './components/JotaiCounter'; export default function HomePage() { return ( <main> <h1>Jotai Counter Example</h1> <JotaiCounter /> </main> ); }
Recoil: Reactフレンドリーな状態管理
RecoilはFacebookが開発した状態管理ライブラリで、Reactのコンポーネントベースの思想と高い親和性を持ちます。アトムとセレクターを組み合わせることで、複雑な派生状態も効率的に管理できます。
特徴とメリット
- Reactの思想と親和性: Reactのフックと同じように自然に状態を扱えます。
- アトムとセレクター: アトムで最小単位の状態を定義し、セレクターでアトムから派生した状態や計算結果を定義できます。これにより、データの流れと依存関係が明確になります。
- 並行モード対応: 将来のReactの並行モードにも対応できるよう設計されています。
- 大規模アプリケーションでの実績: Facebook内部での利用実績があり、大規模なプロジェクトでも安心して利用できます。
Next.js App RouterでのRecoil実装例
RecoilもJotaiと同様にRecoilRootというプロバイダーを必要とします。
-
インストール:
npm install recoil -
RecoilContextProviderの作成 (
app/recoilContextProvider.tsx):
RecoilRootはクライアントサイドでのみ機能するため、"use client";を付与したコンポーネントでラップします。"use client"; import { RecoilRoot } from "recoil"; import React from "react"; export default function RecoilContextProvider({ children, }: { children: React.ReactNode; }) { return <RecoilRoot>{children}</RecoilRoot>; } -
RootLayoutでのラップ (
app/layout.tsx):import "./globals.css"; import RecoilContextProvider from "./recoilContextProvider"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <RecoilContextProvider>{children}</RecoilContextProvider> </body> </html> ); } -
アトムの作成 (
store/counterState.ts):
keyプロパティはアプリケーション全体でユニークである必要があります。import { atom } from 'recoil'; export const counterState = atom({ key: 'counterState', // ユニークなキー default: 0, }); -
クライアントコンポーネントでの利用 (
app/components/RecoilCounter.tsx):"use client"; import { useRecoilState } from 'recoil'; import { counterState } from '@/store/counterState'; export default function RecoilCounter() { const [count, setCount] = useRecoilState(counterState); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> <button onClick={() => setCount(count - 1)}>Decrement</button> </div> ); } -
ページでの利用 (
app/page.tsx):import RecoilCounter from './components/RecoilCounter'; export default function HomePage() { return ( <main> <h1>Recoil Counter Example</h1> <RecoilCounter /> </main> ); }
App Routerでよくある状態管理のハマりどころと回避策
Next.js App Routerで状態管理ライブラリを使用する際に遭遇しやすい問題とその解決策を解説します。
1. ハイドレーションミスマッチ
現象と原因
ハイドレーションミスマッチは、サーバーサイドで生成されたHTMLと、クライアントサイドでJavaScriptが実行された後のDOMツリーが一致しない場合に発生します。Next.jsはアプリケーションをサーバーとクライアントで2回レンダリングするため、両者で異なる出力が生じるとこの問題が起こります。特に、ブラウザのlocalStorageやsessionStorageなど、クライアントサイドでのみ利用可能なAPIをサーバーレンダリング時に参照しようとすると発生しやすいです。Reactは警告を出しつつも自動的に回復しますが、意図しない挙動やパフォーマンス低下の原因になります。
回避策
-
初期値をサーバーとクライアントで同じにする: 状態管理ライブラリで永続化された値(例:
localStorageからの読み込み)を初期値とする場合、サーバーレンダリング時にはデフォルト値を使用し、クライアントサイドでのみ永続化された値を読み込むようにします。 -
クライアント専用コンポーネントとして分離: ブラウザ固有のAPIを使用するコンポーネントは、
"use client"ディレクティブを付与し、さらにnext/dynamicのssr: falseオプションを使ってクライアント側でのみレンダリングするようにします。// app/components/ClientOnlyComponent.tsx "use client"; import { useEffect, useState } from 'react'; export default function ClientOnlyComponent() { const [data, setData] = useState<string | null>(null); useEffect(() => { // localStorageはクライアントサイドでのみアクセス可能 const storedData = localStorage.getItem('my-data'); setData(storedData); }, []); if (data === null) { return <p>Loading client data...</p>; } return <p>Client data: {data}</p>; } // app/page.tsx import dynamic from 'next/dynamic'; const DynamicClientOnlyComponent = dynamic(() => import('./components/ClientOnlyComponent'), { ssr: false, // このコンポーネントはサーバーサイドではレンダリングしない loading: () => <p>Loading...</p>, }); export default function HomePage() { return ( <main> <h1>Home Page</h1> <DynamicClientOnlyComponent /> </main> ); } -
ZustandのSSR対応: Zustandでは、ストアをリクエストごとに作成し、SSRフレンドリーな初期化を行うパターンが推奨されます。具体的には、
create関数をラップするファクトリ関数を定義し、リクエストスコープでインスタンスを生成します。
2. Server Componentsでの状態管理ライブラリの利用
現象と原因
前述の通り、Server Componentsはフックやコンテキストを使用できないため、Zustand、Jotai、Recoilといったクライアントサイドの状態管理ライブラリを直接利用できません。Server Components内でこれらのライブラリの関数を呼び出そうとすると、エラーが発生します。
回避策
-
"use client"によるClient Componentsへの分離: 状態管理が必要な部分は、必ず"use client"を付与したClient Componentsとして切り出します。Server Componentsは、これらのClient ComponentsをPropsとして受け渡す「アイランド」として利用します。 -
Propsを通じたデータの受け渡し: Server Componentsで取得したデータをClient Componentsに渡す必要がある場合は、Propsとして渡します。
// app/components/ClientComponentWithProps.tsx "use client"; import React from 'react'; interface ClientProps { initialData: string; } export default function ClientComponentWithProps({ initialData }: ClientProps) { // initialDataを状態管理ライブラリの初期値として利用するなど return <p>Data from server: {initialData}</p>; } // app/page.tsx (Server Component) import ClientComponentWithProps from './components/ClientComponentWithProps'; async function getServerData() { // サーバーサイドでデータをフェッチ return "Hello from Server!"; } export default async function HomePage() { const data = await getServerData(); return ( <main> <h1>Server Component</h1> <ClientComponentWithProps initialData={data} /> </main> ); } - URLクエリパラメータやサーバーアクションの活用: より複雑な状態共有や、Server ComponentsからClient Componentsへの影響を与えたい場合は、URLクエリパラメータやNext.jsのサーバーアクションを利用することも検討します。
3. Stale Closure問題
現象と原因
useAtomやuseRecoilState、あるいはZustandのフックなどで取得したsetterで値を更新する際、非同期処理内で古い値(Stale Closure)を参照してしまうことがあります。これはJavaScriptのクロージャの性質によるもので、非同期処理が実行される時点での変数の値が、そのクロージャが作成された時点の値(初期値や前回のレンダリング時の値)を参照してしまうためです。
回避策
setterには関数形式(Updater Function)を利用し、常に最新のprev値にアクセスするようにします。これはReactのuseStateでも同様のベストプラクティスです。
// Jotaiの例
import { useAtom } from 'jotai';
import { countAtom } from '@/store/counterAtom';
export default function JotaiCounter() {
const [count, setCount] = useAtom(countAtom);
const incrementAsync = async () => {
await new Promise(resolve => setTimeout(resolve, 1000)); // 非同期処理
// setCount(count + 1); // ❌ Stale Closureの可能性
setCount(prev => prev + 1); // ✅ Updater Functionで最新のprev値にアクセス
};
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementAsync}>Increment Async</button>
</div>
);
}
// Recoilの例
import { useRecoilState } from 'recoil';
import { counterState } from '@/store/counterState';
export default function RecoilCounter() {
const [count, setCount] = useRecoilState(counterState);
const incrementAsync = async () => {
await new Promise(resolve => setTimeout(resolve, 1000)); // 非同期処理
// setCount(count + 1); // ❌ Stale Closureの可能性
setCount(prevCount => prevCount + 1); // ✅ Updater Functionで最新のprev値にアクセス
};
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementAsync}>Increment Async</button>
</div>
);
}
Next.js App Routerにおける状態管理のベストプラクティスとトレードオフ
このセクションでは、App Router環境での状態管理に関する設計上の考慮点と、各ライブラリの選定基準について解説します。
Server ComponentsとClient Componentsの適切な使い分け
App Routerの最大の利点は、Server Componentsによる初期ロードパフォーマンスの向上です。この恩恵を最大限に受けるためには、以下の原則に従うことが重要です。
- デフォルトはServer Components: まずはすべてのコンポーネントをServer Componentsとして設計し、必要に応じてClient Componentsに切り替えます。
- Client Componentsは「アイランド」として: インタラクションや状態管理、ブラウザAPIへのアクセスが必要な部分のみをClient Componentsとして切り出し、最小限のJavaScriptしかクライアントに送らないようにします。
- Server Componentsでのデータフェッチ: 可能な限りサーバー側でデータをフェッチし、PropsとしてClient Componentsに渡します。これにより、クライアントでのデータフェッチが減り、パフォーマンスが向上します。
トレードオフ: Client Componentsを多用しすぎると、大きなクライアントバンドルや高いハイドレーションコストが発生し、App Routerのパフォーマンス上のメリットが薄れます。Server ComponentsとClient Components間のデータ受け渡しはPropsに限定されるため、複雑な状態共有には工夫が必要です。
状態管理ライブラリの選定基準
Zustand、Jotai、Recoilはそれぞれ異なる特性を持つため、プロジェクトの要件やチームの習熟度に合わせて選択することが重要です。
-
Zustand:
- 選定理由: シンプルなAPI、プロバイダー不要、ボイラープレートが少ないため、中小規模のプロジェクトや、Reduxのような複雑な状態管理に疲弊している開発者におすすめです。学習コストが低く、迅速な開発が可能です。
- トレードオフ: アトムベースのライブラリに比べると、非常に細粒度な再レンダリング最適化は手動で工夫する必要がある場合があります。
-
Jotai:
- 選定理由: アトミックな設計により、細粒度な更新が可能で、パフォーマンスが非常に優れています。大規模プロジェクトや、極限のパフォーマンスが求められる場合に適しています。また、派生状態の管理も強力です。
-
トレードオフ: アトムの設計に慣れるまで学習コストがやや高い可能性があります。Recoilと同様に
Providerが必要です。
-
Recoil:
- 選定理由: Facebook製であり、Reactの思想と非常に親和性が高いです。アトムとセレクターによる派生状態の管理が強力で、大規模なアプリケーションでの利用実績もあります。Reactの並行モードへの対応も考慮されています。
-
トレードオフ: Jotaiと同様にアトムの概念や
RecoilRootが必要となるため、学習コストはZustandよりやや高いかもしれません。
避けるべきパターン:
- Reduxのような重厚なライブラリ: 多くのボイラープレートが必要となり、App Routerの軽量な思想とは相性が悪い場合が多いです。
- Context APIの乱用: Context APIはシンプルな状態共有には有用ですが、頻繁に更新される状態を管理すると、不要な再レンダリング(ContextのConsumerコンポーネント全体が再レンダリングされる)が発生しやすく、パフォーマンス問題につながります。
ストアの分割と責務
状態管理ライブラリを使用する際も、ストアの設計は重要です。
- ベストプラクティス: ストアは機能ごと、またはデータの関連性を考慮して分割し、更新頻度の高い状態は別のストアに分けることで管理しやすくなります。これにより、不要な再レンダリングを減らし、パフォーマンスを向上させることができます。例えば、認証情報、UIの状態、ドメインデータなどをそれぞれ別のストアで管理します。
- トレードオフ: ストアを細かく分けすぎると管理が煩雑になる可能性があり、逆に分けなさすぎると単一のストアが肥大化し、再レンダリングの最適化が難しくなります。適切な粒度を見つけることが重要です。
データフェッチとキャッシュ戦略
Next.js App Routerでは、データフェッチとキャッシュの機能が強化されています。クライアントサイドの状態管理ライブラリは、UIの状態管理に専念させ、データフェッチはNext.jsの組み込み機能や専用のライブラリに任せるのが理想的です。
-
ベストプラクティス:
-
Next.jsのData Cache/Full Route Cache:
fetchAPIの拡張機能や、revalidateオプションを積極的に活用し、サーバーとクライアント間のデータ同期を効率的に行います。 -
データフェッチライブラリ:
useSWRやTanstack Query(旧React Query)などのライブラリは、サーバーでフェッチしたデータのハイドレーション、クライアントでの再フェッチ、キャッシュ管理、ローディング・エラー状態のハンドリングを効率的に行います。これらをClient Components内で利用し、UIの状態を状態管理ライブラリで扱うのが良いでしょう。
-
Next.jsのData Cache/Full Route Cache:
- トレードオフ: クライアントサイドの状態管理ライブラリでデータフェッチを行う場合、サーバーキャッシュとの連携やハイドレーションの考慮が必要になり、データの整合性を保つための設計が複雑になります。
まとめ
Next.js App Router時代の状態管理は、Server ComponentsとClient Componentsの適切な使い分けが最も重要です。状態管理ライブラリはClient Components内で使用し、各ライブラリの特性を理解してプロジェクトに最適なものを選びましょう。
- Zustand: シンプルさと手軽さを求めるなら。小〜中規模プロジェクトに最適。
- Jotai: 細粒度なパフォーマンス最適化とアトミックな設計を重視するなら。大規模プロジェクトや高いパフォーマンスが求められる場合に。
- Recoil: Reactの思想と高い親和性を持ち、アトムとセレクターで複雑な状態を管理したいなら。大規模プロジェクトでの実績も豊富。
ハイドレーションミスマッチやServer Componentsでの利用制約など、App Router特有の課題を理解し、適切な回避策を講じることで、パフォーマンスと開発体験を両立させることができます。
この記事で紹介した具体的な実装例やベストプラクティスを参考に、あなたのNext.jsプロジェクトで最適な状態管理を実現してください。さらに深く学ぶには、各ライブラリの公式ドキュメントやNext.jsの公式ドキュメントを参照することをお勧めします。
Comments
Let's comment your feelings that are more than good