State Management戦略¶
はじめに¶
State managementは、フロントエンドアーキテクチャにおける最も重要な側面の1つです。2025年、React Server Components、Server Actions、そして最新のstate managementライブラリにより、この領域は大きく進化しています。このガイドでは、Next.jsアプリケーションにおけるstateを管理するための実践的な戦略について解説します。
State Managementのスペクトラム¶
Stateのカテゴリ¶
| カテゴリ | 例 | 推奨ソリューション |
|---|---|---|
| Server State | APIデータ、DBレコード | React Query、SWR、Server Components |
| URL State | クエリパラメータ、パスパラメータ | Next.js Router、useSearchParams |
| Local UI State | フォーム入力、モーダル、トグル | useState、useReducer |
| Global UI State | テーマ、サイドバーの状態 | Context、Zustand |
| Form State | 複雑なフォーム | React Hook Form、Formik |
| Cache State | Optimistic updates | React Query、Apollo |
ゴールデンルール¶
適切な仕事には適切なツールを使用する。
Server State → React Query / Server Components
Local State → useState / useReducer
Global UI State → Context / Zustand
Form State → React Hook Form
React Query (TanStack Query)¶
なぜReact Queryか?¶
React Queryは2025年におけるserver state managementの事実上の標準です。
利点: - 自動キャッシングとバックグラウンド再取得 - Optimistic updates - 並列クエリと依存クエリ - エラーハンドリングとリトライロジック - デバッグ用のDevtools
基本セットアップ¶
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分
cacheTime: 5 * 60 * 1000, // 5分
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
データの取得¶
// hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
}
async function fetchProducts(): Promise<Product[]> {
const response = await fetch('/api/products');
if (!response.ok) throw new Error('Failed to fetch products');
return response.json();
}
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // 5分間はデータを新鮮と見なす
});
}
// コンポーネントでの使用
function ProductList() {
const { data: products, isLoading, error } = useProducts();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{products?.map(product => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
);
}
Mutations¶
// hooks/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateProductInput {
name: string;
price: number;
}
async function createProduct(input: CreateProductInput): Promise<Product> {
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create product');
return response.json();
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProduct,
onSuccess: () => {
// 製品リストを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// 使用例
function CreateProductForm() {
const createProduct = useCreateProduct();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createProduct.mutate({
name: formData.get('name') as string,
price: Number(formData.get('price')),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit" disabled={createProduct.isPending}>
{createProduct.isPending ? 'Creating...' : 'Create'}
</button>
{createProduct.isError && <p>Error: {createProduct.error.message}</p>}
</form>
);
}
Optimistic Updates¶
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onMutate: async (newProduct) => {
// 外部への再取得をキャンセル
await queryClient.cancelQueries({ queryKey: ['products'] });
// 以前の値をスナップショット
const previousProducts = queryClient.getQueryData(['products']);
// 楽観的に更新
queryClient.setQueryData(['products'], (old: Product[]) =>
old.map(p => p.id === newProduct.id ? newProduct : p)
);
return { previousProducts };
},
onError: (err, newProduct, context) => {
// エラー時にロールバック
queryClient.setQueryData(['products'], context?.previousProducts);
},
onSettled: () => {
// エラーまたは成功後に常に再取得
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Infinite Queries (ページネーション)¶
export function useInfiniteProducts() {
return useInfiniteQuery({
queryKey: ['products', 'infinite'],
queryFn: ({ pageParam = 0 }) => fetchProducts(pageParam),
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
initialPageParam: 0,
});
}
// 使用例
function InfiniteProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteProducts();
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more products'}
</button>
</div>
);
}
Zustand¶
なぜZustandか?¶
Zustandは2025年におけるglobal UI stateの完璧なソリューションです。
利点: - 最小限のボイラープレート - Providerラッパーが不要 - TypeScriptフレンドリー - DevTools統合 - Middleware対応
基本Store¶
// store/uiStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface UiState {
isSidebarOpen: boolean;
theme: 'light' | 'dark';
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useUiStore = create<UiState>()(
devtools(
persist(
(set) => ({
isSidebarOpen: false,
theme: 'light',
toggleSidebar: () => set((state) => ({
isSidebarOpen: !state.isSidebarOpen
})),
setTheme: (theme) => set({ theme }),
}),
{ name: 'ui-storage' }
)
)
);
// 使用例
function Sidebar() {
const { isSidebarOpen, toggleSidebar } = useUiStore();
return (
<aside className={isSidebarOpen ? 'open' : 'closed'}>
<button onClick={toggleSidebar}>Toggle</button>
</aside>
);
}
Slicesパターン¶
大規模アプリケーションの場合、storeをslicesに分割:
// store/slices/authSlice.ts
export interface AuthSlice {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
});
// store/slices/cartSlice.ts
export interface CartSlice {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const createCartSlice: StateCreator<CartSlice> = (set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
clearCart: () => set({ items: [] }),
});
// store/index.ts
import { create } from 'zustand';
type StoreState = AuthSlice & CartSlice;
export const useStore = create<StoreState>()((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
}));
算出値¶
interface ProductStore {
products: Product[];
filter: string;
setFilter: (filter: string) => void;
}
export const useProductStore = create<ProductStore>((set) => ({
products: [],
filter: '',
setFilter: (filter) => set({ filter }),
}));
// 算出セレクター
export const useFilteredProducts = () => {
const products = useProductStore(state => state.products);
const filter = useProductStore(state => state.filter);
return products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
};
React Context¶
Contextを使用するタイミング¶
Contextが理想的なのは: - テーマプロバイダー - 認証状態 - ローカライゼーション - 機能フラグ
Contextを使用すべきでない場合: - 頻繁に変更される値(再レンダリングの原因となる) - Server state(代わりにReact Queryを使用)
最適なContextパターン¶
// context/ThemeContext.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
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 within ThemeProvider');
}
return context;
}
パフォーマンス最適化¶
不要な再レンダリングを避けるためにcontextを分割:
// ❌ 悪い例: 単一のcontextがすべてのconsumerを再レンダリングさせる
const AppContext = createContext({ user, theme, cart });
// ✅ 良い例: 個別のcontext
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const CartContext = createContext(cart);
Zustand × React Queryパターン¶
これは2025年のNext.jsアプリケーションで推奨されるパターンです。
責任の分離¶
// ✅ Server State → React Query
const { data: products } = useQuery(['products'], fetchProducts);
// ✅ UI State → Zustand
const { isSidebarOpen, toggleSidebar } = useUiStore();
// ✅ Session State → Zustand(永続化あり)
const { user, token } = useAuthStore();
// ❌ やってはいけない: ZustandでのServer state
// これはReact Queryのキャッシングと再取得をバイパスする
例: ショッピングカート¶
// hooks/useProducts.ts - Server state
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
}
// store/cartStore.ts - UI state
export const useCartStore = create<CartState>((set) => ({
items: [],
addToCart: (productId: string, quantity: number) =>
set((state) => ({
items: [...state.items, { productId, quantity }]
})),
removeFromCart: (productId: string) =>
set((state) => ({
items: state.items.filter(i => i.productId !== productId)
})),
}));
// components/ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
const addToCart = useCartStore(state => state.addToCart);
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => addToCart(product.id, 1)}>
Add to Cart
</button>
</div>
);
}
// components/Cart.tsx
function Cart() {
const cartItems = useCartStore(state => state.items);
const { data: products } = useProducts(); // Server state
const cartProducts = cartItems.map(item => ({
...products?.find(p => p.id === item.productId),
quantity: item.quantity
}));
return (
<div>
{cartProducts.map(item => (
<CartItem key={item.id} item={item} />
))}
</div>
);
}
Server Components + Server Actions¶
データ取得のためのServer Components¶
// app/products/page.tsx (Server Component)
import { getProducts } from '@/lib/api';
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// revalidateによる自動キャッシング
export const revalidate = 60; // 60秒ごとに再検証
MutationsのためのServer Actions¶
// app/actions/productActions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string;
const price = Number(formData.get('price'));
await fetch('https://api.example.com/products', {
method: 'POST',
body: JSON.stringify({ name, price }),
});
revalidatePath('/products');
return { success: true };
}
useFormState Hook¶
// components/CreateProductForm.tsx
'use client';
import { useFormState } from 'react-dom';
import { createProduct } from '@/app/actions/productActions';
export function CreateProductForm() {
const [state, formAction] = useFormState(createProduct, null);
return (
<form action={formAction}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit">Create</button>
{state?.success && <p>Product created!</p>}
</form>
);
}
Form State Management¶
React Hook Form¶
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const productSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
price: z.number().positive('Price must be positive'),
description: z.string().optional(),
});
type ProductFormData = z.infer<typeof productSchema>;
export function ProductForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
});
const onSubmit = async (data: ProductFormData) => {
await createProduct(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<input type="number" {...register('price', { valueAsNumber: true })} />
{errors.price && <p>{errors.price.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Product'}
</button>
</form>
);
}
State Management決定木¶
Stateを管理する必要がある?
│
├─ APIやデータベースからのものか?
│ └─ YES → React Query / SWR / Server Components
│
├─ URL関連(検索、フィルター)か?
│ └─ YES → useSearchParams / Next.js Router
│
├─ フォームデータか?
│ ├─ シンプルなフォーム → useState
│ └─ 複雑なフォーム → React Hook Form
│
├─ 多数のコンポーネント間で共有する必要があるか?
│ ├─ YES → UI stateか?
│ │ ├─ YES → Context / Zustand
│ │ └─ NO → React Query(server dataの場合)
│ └─ NO → useState / useReducer(local state)
│
└─ 認証/セッションか?
└─ Zustand(永続化あり)
ベストプラクティス¶
すべきこと¶
✅ Server stateとUI stateを分離
✅ Stateを使用される場所の近くに配置
// 必要なときだけstateを持ち上げる
function ParentComponent() {
// すべてをここに置かない
return <ChildComponent />;
}
function ChildComponent() {
const [localState, setLocalState] = useState(); // ローカルに保持
}
✅ Stateの安全性のためにTypeScriptを使用
してはいけないこと¶
❌ Server stateをZustand/Contextに入れない
// 悪い例: React Queryの機能をバイパス
const useStore = create(set => ({
products: [],
fetchProducts: async () => {
const data = await fetch('/api/products');
set({ products: data });
}
}));
❌ 頻繁に変更される値にContextを使用しない
❌ State managementを過度に設計しない
// 悪い例: シンプルなlocal stateにZustand
const useModalStore = create(set => ({
isOpen: false,
toggle: () => set(state => ({ isOpen: !state.isOpen }))
}));
// 良い例: useStateを使用
const [isOpen, setIsOpen] = useState(false);
一般的なパターン¶
Loading States¶
function ProductList() {
const { data, isLoading, error } = useProducts();
if (isLoading) return <Skeleton />;
if (error) return <Error error={error} />;
if (!data?.length) return <EmptyState />;
return <div>{data.map(...)}</div>;
}
Optimistic UI¶
const updateProduct = useMutation({
mutationFn: api.updateProduct,
onMutate: async (newProduct) => {
// 即座に更新を表示
queryClient.setQueryData(['product', id], newProduct);
},
onError: (err, variables, context) => {
// エラー時にロールバック
queryClient.setQueryData(['product', id], context.previousProduct);
},
});
Polling¶
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30000, // 30秒ごとにポーリング
});
リソース¶
まとめ¶
2025年のState Managementスタック:
| State Type | ソリューション |
|---|---|
| Server Data | React Query + Server Components |
| Global UI | Zustand |
| Local UI | useState / useReducer |
| Forms | React Hook Form + Zod |
| URL State | Next.js Router |
| Auth/Session | Zustand + Persistence |
重要な原則: - Server state ≠ Client state - Stateを可能な限りローカルに保持 - 各仕事に適切なツールを使用 - 再レンダリングの最適化 - TypeScriptですべてを型付け