長らく、習慣的にあらゆるプロジェクトでReduxを採用してきました。クライアント側の状態をほとんど必要としないプロジェクトでさえもです。Next.js 15によってその習慣は変わりました。かつてグローバルストアに置かれていたものの大部分は、今やサーバー側に属するようになりました。残されたクライアント側の状態は、通常Reduxが過剰になるほど小規模です。
ここでは、実際にある状態をどこに配置すべきかをどのように判断しているかを紹介します。
1. ほとんどの状態は、もはやクライアントに置くべきではありません
これは古いReactアプリからの最大の変化です。データベースから取得したデータ、ユーザープロフィール情報、投稿一覧——これらはすべて、Server Component内で直接取得すれば、クライアント側のストアを必要としません。
// app/dashboard/page.tsx
import { getUser } from '@/lib/queries/users';
import { getPosts } from '@/lib/queries/posts';
export default async function DashboardPage() {
const user = await getUser();
const posts = await getPosts(user.id);
return (
<div>
<h1>Welcome, {user.name}</h1>
<PostList posts={posts} />
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
useEffectも、loading状態も、このデータを保持するためのグローバルストアも必要ありません。使用する場所で取得し、propsとして渡すだけです。これだけで、古いアプリでReduxやContextを必要としていたものの大部分が不要になります。
2. 少数の関連コンポーネントで共有する状態にはReact Context
Contextにはまだ役割があります。特に、少数の関連するコンポーネント群が必要とし、毎回キーストロークで変化しない状態に適しています。
// context/ThemeContext.tsx
'use client';
import { createContext, useContext, useState } from 'react';
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used inside ThemeProvider');
return context;
}
Enter fullscreen mode Exit fullscreen mode
テーマ、サイドバーの折りたたみ状態、モーダルの開閉状態——これらは変化が少なく、わずかなコンポーネントだけが関心を持つため、Contextに適しています。
3. Contextが問題を引き起こし始める場面
Contextは値が変化するたびに、それを読むすべてのコンポーネントを再レンダリングします。値の一部だけを購読する組み込みの方法はありません。ほとんど変化しないテーマのような状態には問題ありませんが、頻繁に更新される状態では深刻な問題になります。
// これはすべてのコンシューマーが毎回のキーストロークで再レンダリングされる原因になります
const [searchState, setSearchState] = useState({ query: '', filters: {}, results: [] });
Enter fullscreen mode Exit fullscreen mode
検索入力、フィルターパネル、結果一覧がすべてこのようなContextの値を読み取る場合、1文字入力するだけで、キーストローク自体とは無関係のコンポーネントまで含めてすべてが再レンダリングされます。通常、この時点で頻繁な更新向けに設計されたものに切り替えます。
4. 頻繁に更新される、または無関係のコンポーネント間で共有される状態にはZustand
Contextが不必要な再レンダリングを引き起こし始めたり、状態を自然にネストされていないコンポーネントから読み書きする必要が出てきたりしたら、Zustandを採用します。
// store/useCartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
Enter fullscreen mode Exit fullscreen mode
// components/CartButton.tsx
'use client';
import { useCartStore } from '@/store/useCartStore';
export function CartButton() {
const items = useCartStore((state) => state.items);
return <button>Cart ({items.length})</button>;
}
Enter fullscreen mode Exit fullscreen mode
Contextとの主な違いは、最後の行にあります。useCartStore((state) => state.items)はitemsが具体的に変化したときだけこのコンポーネントを再レンダリングし、ストア全体の更新ごとに再レンダリングすることはありません。他の場所でtotal()を読み取るコンポーネントは、カートの状態の無関係な部分が変わっただけでは再レンダリングされません。
5. サーバーステートとクライアントステートをきれいに組み合わせる
よくある間違いは、サーバーから取得したデータとクライアント専用の状態を同じ種類のものとして扱うことです。両者は異なり、混在させると古いデータによるバグを引き起こします。
// app/products/[id]/page.tsx
import { getProduct } from '@/lib/queries/products';
import { AddToCartButton } from '@/components/AddToCartButton';
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id); // server state, fetched fresh
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton product={product} /> {/* client state, cart store */}
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
商品データはリクエストごとにサーバーから新鮮に取得されます。カート自体(ユーザーが実際に追加したもの)は、ナビゲーションをまたいで保持され、再取得を必要としないクライアント状態です。この2つを明確に分けることで、クライアント状態がサーバーデータのコピーを保持してしまい古くなるバグを防げます。
6. リロードをまたいでクライアント状態を保持する
カートや下書きフォームデータのように、ページのリロード後も保持すべき状態には、Zustandの組み込みpersistミドルウェアを使います。
// store/useCartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: 'cart-storage' }
)
);
Enter fullscreen mode Exit fullscreen mode
これにより、手動で保存・読み込みのロジックを書かなくても、ストアが自動的にlocalStorageに保存され、ロード時に復元されます。
実際の判断方法
データベース由来のデータですか? サーバーで取得してください。ストアは不要です。
少数の関連コンポーネントで共有され、変化が少ないですか? Contextを使います。
頻繁に更新される、またはアプリ全体に散らばったコンポーネントから読み取られますか? Zustandを使います。
ページのリロード後も保持する必要がありますか? persistミドルウェア付きのZustandを使います。
ほとんどのプロジェクトでは、局面に応じてこの3つすべてを使い分けます。大量のデータにはサーバーフェッチ、UIレベルのトグルにはContext、カートや複数ステップのフォームなど、本当に高速で横断的なクライアント状態が必要なものにはZustandです。
まとめ
| ツール | 用途 |
|---|---|
| Server Components | データベースやAPI由来のデータ。クライアントストア不要 |
| React Context | 少数の関連コンポーネント、更新頻度が低い場合 |
| Zustand | 頻繁な更新、または無関係のコンポーネント間で読み取られる状態 |
| Zustand + persist | ページのリロード後も保持すべきクライアント状態 |
Next.js 15の本当の変化は、どの状態ライブラリが勝つかではありません。ほとんどの状態が、もはやクライアントライブラリを必要としなくなった点です。サーバーで取得したデータが除外されると、残るものは通常小さく、ContextとZustandの選択は「どれくらいの頻度で変化するか」に帰着します。
私はこの分割を実際に使用しています。サーバーフェッチ、UIトグルにはContext、カートのようなものにはZustand——構築するダッシュボードやテンプレート全般でこのパターンを採用しています。
実際のコードベースで確認する:
テンプレートを入手する: https://pixelanas.gumroad.com
まだReduxを使っていますか?それともZustandに置き換えましたか?下のコメント欄でお聞かせください 👇
Anas — SaaS製品とプレミアムテンプレートを開発するフルスタックNext.js開発者。X: @ASheikh69751
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.