コンテンツにスキップ

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 フォーム入力、モーダル、トグル useStateuseReducer
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を分離

const { data } = useQuery(['users'], fetchUsers); // Server
const { theme } = useUiStore(); // UI

Stateを使用される場所の近くに配置

// 必要なときだけstateを持ち上げる
function ParentComponent() {
  // すべてをここに置かない
  return <ChildComponent />;
}

function ChildComponent() {
  const [localState, setLocalState] = useState(); // ローカルに保持
}

Stateの安全性のためにTypeScriptを使用

interface State {
  user: User | null;
  isLoading: boolean;
}

してはいけないこと

Server stateをZustand/Contextに入れない

// 悪い例: React Queryの機能をバイパス
const useStore = create(set => ({
  products: [],
  fetchProducts: async () => {
    const data = await fetch('/api/products');
    set({ products: data });
  }
}));

頻繁に変更される値にContextを使用しない

// 悪い例: 不要な再レンダリングの原因
const MouseContext = createContext({ x: 0, y: 0 });

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ですべてを型付け