T-CREATOR

Next.js での Zustand 活用法:App Router 時代のステート設計

Next.js での Zustand 活用法:App Router 時代のステート設計

Next.js の進化とともに、フロントエンド開発の景色は大きく変わりました。特に App Router の登場により、Server Components と Client Components が共存する新しい開発環境が生まれ、従来のステート管理のアプローチでは対応しきれない課題が浮上しています。

この変化の波の中で、「どうやって App Router でステート管理を行えばいいの?」「Server Components と Client Components で state を共有するにはどうすればいい?」「従来の Pages Router とは何が違うの?」といった疑問を抱えている開発者の方も多いのではないでしょうか。

今回は、これらの疑問を解決し、App Router 時代における効果的な Zustand の活用方法をご紹介いたします。新しい Next.js の機能を最大限に活かしながら、シンプルで保守性の高いステート管理を実現する方法を、実践的なコード例とともに詳しく解説していきますね。

背景

Next.js App Router の登場とステート管理の変化

Next.js 13 で導入された App Router は、従来の Pages Router とは根本的に異なるアーキテクチャを採用しています。この変化により、ステート管理の考え方も大きく変わりました。

App Router の主要な特徴を整理してみましょう。

#特徴従来の Pages Router新しい App Router
1レンダリングCSR/SSR の明示的な選択Server/Client Components の組み合わせ
2ファイル構造pages/ディレクトリベースapp/ディレクトリの階層構造
3ステート管理クライアントサイドが中心サーバー/クライアント混在
4データフェッチgetServerSideProps/getStaticPropsfetch API 拡張と Server Components
5パフォーマンスバンドルサイズの課題Server Components による最適化

この変化により、ステート管理ライブラリにも新たな要求が生まれています。特に、サーバーサイドとクライアントサイドの境界を意識した設計が重要になっているのです。

typescript// 従来のPages Routerでの典型的なパターン
// pages/index.tsx
import { useStore } from '../store/useStore';

export default function HomePage() {
  const { count, increment } = useStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}
typescript// App Routerでの新しいパターン
// app/page.tsx (Server Component)
import { Counter } from './counter';

export default function HomePage() {
  return (
    <div>
      <h1>Welcome to App Router</h1>
      <Counter /> {/* Client Componentとして分離 */}
    </div>
  );
}

Server Components と Client Components の共存環境

App Router の最も革新的な機能の一つが、Server Components と Client Components の共存です。この環境では、以下のような特徴があります。

Server Components の特徴

  • サーバーサイドでレンダリング
  • JavaScript バンドルに含まれない
  • データベースアクセスなどのサーバーサイド処理が可能
  • インタラクティブな機能は持てない

Client Components の特徴

  • ブラウザでレンダリング
  • JavaScript バンドルに含まれる
  • インタラクティブな機能を持つ
  • 'use client'ディレクティブが必要
typescript// Server Component(デフォルト)
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <nav>Global Navigation</nav>
        {children}
      </body>
    </html>
  );
}
typescript// Client Component
// app/components/interactive-counter.tsx
'use client';

import { useStore } from '../store/counter-store';

export function InteractiveCounter() {
  const { count, increment, decrement } = useStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

従来の Pages Router との違い

Pages Router と App Router の違いは、ステート管理の観点からも重要です。主な違いを詳しく見てみましょう。

ファイルベースルーティングの変化

typescript// Pages Router
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { StoreProvider } from '../providers/store-provider';

export default function App({
  Component,
  pageProps,
}: AppProps) {
  return (
    <StoreProvider>
      <Component {...pageProps} />
    </StoreProvider>
  );
}
typescript// App Router
// app/layout.tsx
import { StoreInitializer } from './components/store-initializer';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <StoreInitializer />
        {children}
      </body>
    </html>
  );
}

データフェッチングの変化

typescript// Pages Router
// pages/products/[id].tsx
import { GetServerSideProps } from 'next';
import { useStore } from '../../store/product-store';

interface Props {
  initialProduct: Product;
}

export default function ProductPage({
  initialProduct,
}: Props) {
  const { product, setProduct } = useStore();

  useEffect(() => {
    setProduct(initialProduct);
  }, [initialProduct, setProduct]);

  return <div>{product.name}</div>;
}

export const getServerSideProps: GetServerSideProps =
  async ({ params }) => {
    const product = await fetchProduct(
      params?.id as string
    );
    return { props: { initialProduct: product } };
  };
typescript// App Router
// app/products/[id]/page.tsx
import { ProductDisplay } from './product-display';

async function fetchProduct(id: string) {
  const res = await fetch(
    `https://api.example.com/products/${id}`
  );
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await fetchProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <ProductDisplay initialProduct={product} />
    </div>
  );
}

課題

App Router での SSR/RSC とクライアントステートの境界線

App Router での最大の課題の一つが、Server Components で取得したデータと Client Components で管理するステートとの境界線を適切に設計することです。

この境界線が曖昧になると、以下のような問題が発生します。

typescript// 問題のあるパターン:境界線が不明確
// app/dashboard/page.tsx
import { UserProfile } from './user-profile';

// Server Componentでデータを取得
export default async function DashboardPage() {
  const userData = await fetchUserData();

  return (
    <div>
      <h1>Dashboard</h1>
      {/* この渡し方では、userData の変更を追跡できない */}
      <UserProfile user={userData} />
    </div>
  );
}
typescript// app/dashboard/user-profile.tsx
'use client';

interface Props {
  user: User;
}

export function UserProfile({ user }: Props) {
  // propsで受け取ったデータをどうやって更新する?
  // 他のコンポーネントとどうやって共有する?

  const updateProfile = async (newData: Partial<User>) => {
    // APIを呼び出すが、parent componentに反映されない
    await updateUserAPI(newData);
  };

  return (
    <div>
      <p>{user.name}</p>
      <button
        onClick={() => updateProfile({ name: 'New Name' })}
      >
        Update Name
      </button>
    </div>
  );
}

Server Components と Client Components での state 共有

Server Components と Client Components の間での state 共有は、従来の React アプリケーションでは考慮する必要がなかった新しい課題です。

具体的な問題点を見てみましょう。

typescript// 課題のあるパターン
// app/layout.tsx (Server Component)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Server ComponentではuseStateやuseStoreは使用できない
  // どうやってグローバルステートを初期化する?

  return (
    <html lang='ja'>
      <body>
        <Header /> {/* Client Component */}
        <main>{children}</main>
        <Footer /> {/* Server Component */}
      </body>
    </html>
  );
}
typescript// app/components/header.tsx
'use client';

import { useStore } from '../store/navigation-store';

export function Header() {
  const { isMenuOpen, toggleMenu } = useStore();

  return (
    <header>
      <button onClick={toggleMenu}>
        {isMenuOpen ? 'Close' : 'Open'} Menu
      </button>
      {/* このstateを他のClient Componentsと共有したい */}
    </header>
  );
}

ハイドレーション時のステート同期問題

ハイドレーション時のステート同期は、SSR や RSC を使用する際の重要な課題です。サーバーサイドで生成された HTML とクライアントサイドのステートが一致しない場合、ハイドレーションエラーが発生します。

typescript// ハイドレーションエラーの例
// app/components/theme-toggle.tsx
'use client';

import { useStore } from '../store/theme-store';

export function ThemeToggle() {
  const { theme, toggleTheme } = useStore();

  // 初期状態でthemeがundefinedの場合、
  // サーバーサイドとクライアントサイドで異なる内容がレンダリングされる
  return (
    <button onClick={toggleTheme}>
      Current theme: {theme || 'loading...'}{' '}
      {/* ハイドレーションエラー */}
    </button>
  );
}

この問題を解決するには、適切な初期化戦略が必要になります。

解決策

Zustand の App Router 対応パターン

Zustand は Provider 不要でシンプルな API を持つため、App Router 環境でも効果的に活用できます。適切なパターンを使用することで、Server Components と Client Components の境界を明確にしながら、効率的なステート管理を実現できるでしょう。

基本的な App Router 対応パターンをご紹介します。

typescript// store/counter-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () =>
        set((state) => ({ count: state.count + 1 })),
      decrement: () =>
        set((state) => ({ count: state.count - 1 })),
      reset: () => set({ count: 0 }),
    }),
    {
      name: 'counter-storage', // LocalStorageのキー名
      skipHydration: true, // ハイドレーション問題を回避
    }
  )
);
typescript// app/components/counter.tsx
'use client';

import { useEffect, useState } from 'react';
import { useCounterStore } from '../store/counter-store';

export function Counter() {
  const [isClient, setIsClient] = useState(false);
  const { count, increment, decrement, reset } =
    useCounterStore();

  useEffect(() => {
    // クライアントサイドでのハイドレーション完了後に表示
    setIsClient(true);
    useCounterStore.persist.rehydrate();
  }, []);

  if (!isClient) {
    return <div>Loading...</div>; // ハイドレーション完了まで待機
  }

  return (
    <div>
      <p>Count: {count}</p>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}

'use client'ディレクティブの適切な使用

'use client'ディレクティブの配置は、パフォーマンスとユーザビリティに直接影響するため、戦略的に行う必要があります。

効果的な配置パターンをご紹介しましょう。

typescript// 良いパターン:最小限のClient Componentに分離
// app/page.tsx (Server Component)
import { ProductList } from './components/product-list';
import { SearchForm } from './components/search-form';

export default async function HomePage() {
  const initialProducts = await fetchProducts();

  return (
    <div>
      <h1>Product Store</h1>
      <SearchForm />
      <ProductList initialProducts={initialProducts} />
    </div>
  );
}
typescript// app/components/search-form.tsx (Client Component)
'use client';

import { useRouter } from 'next/navigation';
import { useProductStore } from '../store/product-store';

export function SearchForm() {
  const router = useRouter();
  const { searchQuery, setSearchQuery, searchProducts } =
    useProductStore();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await searchProducts(searchQuery);
    router.push(
      `/search?q=${encodeURIComponent(searchQuery)}`
    );
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='text'
        value={searchQuery}
        onChange={(e) => setSearchQuery(e.target.value)}
        placeholder='商品を検索...'
      />
      <button type='submit'>検索</button>
    </form>
  );
}
typescript// app/components/product-list.tsx (Server + Client の hybrid)
import { ProductCard } from './product-card';

interface Props {
  initialProducts: Product[];
}

export function ProductList({ initialProducts }: Props) {
  return (
    <div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
      {initialProducts.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
typescript// app/components/product-card.tsx (Client Component)
'use client';

import { useCartStore } from '../store/cart-store';

interface Props {
  product: Product;
}

export function ProductCard({ product }: Props) {
  const { addToCart } = useCartStore();

  return (
    <div className='border rounded-lg p-4'>
      <h3>{product.name}</h3>
      <p>{product.price}円</p>
      <button
        onClick={() => addToCart(product)}
        className='bg-blue-500 text-white px-4 py-2 rounded'
      >
        カートに追加
      </button>
    </div>
  );
}

Provider 不要のシンプルな実装方法

Zustand の大きな利点の一つが、Provider 不要でシンプルな実装ができることです。App Router 環境でも、この利点を最大限に活用できます。

typescript// store/auth-store.ts
import { create } from 'zustand';
import {
  persist,
  subscribeWithSelector,
} from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  updateProfile: (updates: Partial<User>) => Promise<void>;
}

export const useAuthStore = create<AuthState>()(
  subscribeWithSelector(
    persist(
      (set, get) => ({
        user: null,
        isAuthenticated: false,
        isLoading: false,

        login: async (email: string, password: string) => {
          set({ isLoading: true });
          try {
            const response = await fetch(
              '/api/auth/login',
              {
                method: 'POST',
                headers: {
                  'Content-Type': 'application/json',
                },
                body: JSON.stringify({ email, password }),
              }
            );

            if (response.ok) {
              const user = await response.json();
              set({
                user,
                isAuthenticated: true,
                isLoading: false,
              });
            } else {
              throw new Error('Login failed');
            }
          } catch (error) {
            set({ isLoading: false });
            throw error;
          }
        },

        logout: () => {
          fetch('/api/auth/logout', { method: 'POST' });
          set({ user: null, isAuthenticated: false });
        },

        updateProfile: async (updates: Partial<User>) => {
          const { user } = get();
          if (!user) return;

          try {
            const response = await fetch(
              `/api/users/${user.id}`,
              {
                method: 'PATCH',
                headers: {
                  'Content-Type': 'application/json',
                },
                body: JSON.stringify(updates),
              }
            );

            if (response.ok) {
              const updatedUser = await response.json();
              set({ user: updatedUser });
            }
          } catch (error) {
            console.error('Profile update failed:', error);
            throw error;
          }
        },
      }),
      {
        name: 'auth-storage',
        partialize: (state) => ({
          user: state.user,
          isAuthenticated: state.isAuthenticated,
        }),
      }
    )
  )
);

// 認証状態の変化を監視するヘルパー
export const subscribeToAuth = (
  callback: (state: AuthState) => void
) => {
  return useAuthStore.subscribe(callback);
};
typescript// app/components/auth-provider.tsx
'use client';

import { useEffect } from 'react';
import { useAuthStore } from '../store/auth-store';

export function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  useEffect(() => {
    // ページロード時に認証状態を復元
    useAuthStore.persist.rehydrate();

    // 認証状態の変化を監視
    const unsubscribe = useAuthStore.subscribe(
      (state) => state.isAuthenticated,
      (isAuthenticated) => {
        if (isAuthenticated) {
          console.log('User logged in');
        } else {
          console.log('User logged out');
        }
      }
    );

    return unsubscribe;
  }, []);

  return <>{children}</>;
}

具体例

Server Components と Client Components でのステート共有

実際のアプリケーションで Server Components と Client Components でステートを共有する具体的なパターンを見てみましょう。

typescript// store/user-store.ts
import { create } from 'zustand';

interface UserProfile {
  id: string;
  name: string;
  email: string;
  preferences: {
    theme: 'light' | 'dark';
    language: 'ja' | 'en';
    notifications: boolean;
  };
}

interface UserState {
  profile: UserProfile | null;
  isLoading: boolean;
  error: string | null;

  setProfile: (profile: UserProfile) => void;
  updatePreferences: (
    preferences: Partial<UserProfile['preferences']>
  ) => void;
  clearUser: () => void;
}

export const useUserStore = create<UserState>(
  (set, get) => ({
    profile: null,
    isLoading: false,
    error: null,

    setProfile: (profile) => set({ profile, error: null }),

    updatePreferences: async (preferences) => {
      const { profile } = get();
      if (!profile) return;

      set({ isLoading: true });
      try {
        const response = await fetch(
          `/api/users/${profile.id}/preferences`,
          {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(preferences),
          }
        );

        if (response.ok) {
          const updatedProfile = {
            ...profile,
            preferences: {
              ...profile.preferences,
              ...preferences,
            },
          };
          set({
            profile: updatedProfile,
            isLoading: false,
          });
        }
      } catch (error) {
        set({
          error: 'Failed to update preferences',
          isLoading: false,
        });
      }
    },

    clearUser: () => set({ profile: null, error: null }),
  })
);
typescript// app/profile/page.tsx (Server Component)
import { ProfileForm } from './profile-form';
import { ProfileInitializer } from './profile-initializer';

async function getUserProfile(
  userId: string
): Promise<UserProfile> {
  const response = await fetch(
    `https://api.example.com/users/${userId}`,
    {
      cache: 'no-store', // 常に最新データを取得
    }
  );
  return response.json();
}

export default async function ProfilePage({
  params,
}: {
  params: { userId: string };
}) {
  const userProfile = await getUserProfile(params.userId);

  return (
    <div>
      <h1>プロフィール設定</h1>
      <ProfileInitializer initialProfile={userProfile} />
      <ProfileForm />
    </div>
  );
}
typescript// app/profile/profile-initializer.tsx (Client Component)
'use client';

import { useEffect } from 'react';
import { useUserStore } from '../store/user-store';

interface Props {
  initialProfile: UserProfile;
}

export function ProfileInitializer({
  initialProfile,
}: Props) {
  const setProfile = useUserStore(
    (state) => state.setProfile
  );

  useEffect(() => {
    // Server Componentから受け取ったデータでstoreを初期化
    setProfile(initialProfile);
  }, [initialProfile, setProfile]);

  return null; // UIを持たない初期化専用コンポーネント
}
typescript// app/profile/profile-form.tsx (Client Component)
'use client';

import { useUserStore } from '../store/user-store';

export function ProfileForm() {
  const { profile, isLoading, error, updatePreferences } =
    useUserStore();

  if (!profile) {
    return <div>Loading profile...</div>;
  }

  const handleThemeChange = (theme: 'light' | 'dark') => {
    updatePreferences({ theme });
  };

  const handleLanguageChange = (language: 'ja' | 'en') => {
    updatePreferences({ language });
  };

  return (
    <form className='space-y-4'>
      <div>
        <label>名前</label>
        <p>{profile.name}</p>
      </div>

      <div>
        <label>テーマ</label>
        <select
          value={profile.preferences.theme}
          onChange={(e) =>
            handleThemeChange(
              e.target.value as 'light' | 'dark'
            )
          }
          disabled={isLoading}
        >
          <option value='light'>ライト</option>
          <option value='dark'>ダーク</option>
        </select>
      </div>

      <div>
        <label>言語</label>
        <select
          value={profile.preferences.language}
          onChange={(e) =>
            handleLanguageChange(
              e.target.value as 'ja' | 'en'
            )
          }
          disabled={isLoading}
        >
          <option value='ja'>日本語</option>
          <option value='en'>English</option>
        </select>
      </div>

      {error && <div className='text-red-500'>{error}</div>}
    </form>
  );
}

App Router でのページ間ステート保持

App Router でページ間でのステート保持を実現する実装例をご紹介します。

typescript// store/shopping-cart-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartState {
  items: CartItem[];
  isOpen: boolean;

  addItem: (product: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;

  // 計算プロパティ
  getTotalItems: () => number;
  getTotalPrice: () => number;
}

export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      isOpen: false,

      addItem: (product) => {
        const { items } = get();
        const existingItem = items.find(
          (item) => item.id === product.id
        );

        if (existingItem) {
          set({
            items: items.map((item) =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            ),
          });
        } else {
          set({
            items: [...items, { ...product, quantity: 1 }],
          });
        }
      },

      removeItem: (id) => {
        set({
          items: get().items.filter(
            (item) => item.id !== id
          ),
        });
      },

      updateQuantity: (id, quantity) => {
        if (quantity <= 0) {
          get().removeItem(id);
          return;
        }

        set({
          items: get().items.map((item) =>
            item.id === id ? { ...item, quantity } : item
          ),
        });
      },

      clearCart: () => set({ items: [] }),

      toggleCart: () => set({ isOpen: !get().isOpen }),

      getTotalItems: () => {
        return get().items.reduce(
          (total, item) => total + item.quantity,
          0
        );
      },

      getTotalPrice: () => {
        return get().items.reduce(
          (total, item) =>
            total + item.price * item.quantity,
          0
        );
      },
    }),
    {
      name: 'shopping-cart',
      partialize: (state) => ({ items: state.items }), // isOpenは永続化しない
    }
  )
);
typescript// app/components/cart-icon.tsx (Client Component)
'use client';

import { useCartStore } from '../store/shopping-cart-store';

export function CartIcon() {
  const { getTotalItems, toggleCart, isOpen } =
    useCartStore();
  const totalItems = getTotalItems();

  return (
    <button
      onClick={toggleCart}
      className='relative p-2 hover:bg-gray-100 rounded-full'
    >
      🛒
      {totalItems > 0 && (
        <span className='absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center'>
          {totalItems}
        </span>
      )}
    </button>
  );
}
typescript// app/components/cart-sidebar.tsx (Client Component)
'use client';

import { useCartStore } from '../store/shopping-cart-store';

export function CartSidebar() {
  const {
    items,
    isOpen,
    removeItem,
    updateQuantity,
    clearCart,
    toggleCart,
    getTotalPrice,
  } = useCartStore();

  if (!isOpen) return null;

  const totalPrice = getTotalPrice();

  return (
    <div className='fixed inset-0 z-50 overflow-hidden'>
      {/* Overlay */}
      <div
        className='absolute inset-0 bg-black bg-opacity-50'
        onClick={toggleCart}
      />

      {/* Sidebar */}
      <div className='absolute right-0 top-0 h-full w-96 bg-white shadow-xl'>
        <div className='flex items-center justify-between p-4 border-b'>
          <h2 className='text-lg font-semibold'>
            ショッピングカート
          </h2>
          <button onClick={toggleCart}></button>
        </div>

        <div className='flex-1 overflow-y-auto p-4'>
          {items.length === 0 ? (
            <p>カートは空です</p>
          ) : (
            <>
              {items.map((item) => (
                <div
                  key={item.id}
                  className='flex items-center space-x-4 mb-4'
                >
                  <img
                    src={item.image}
                    alt={item.name}
                    className='w-16 h-16 object-cover rounded'
                  />
                  <div className='flex-1'>
                    <h3>{item.name}</h3>
                    <p>¥{item.price}</p>
                    <div className='flex items-center space-x-2'>
                      <button
                        onClick={() =>
                          updateQuantity(
                            item.id,
                            item.quantity - 1
                          )
                        }
                      >
                        -
                      </button>
                      <span>{item.quantity}</span>
                      <button
                        onClick={() =>
                          updateQuantity(
                            item.id,
                            item.quantity + 1
                          )
                        }
                      >
                        +
                      </button>
                    </div>
                  </div>
                  <button
                    onClick={() => removeItem(item.id)}
                    className='text-red-500'
                  >
                    削除
                  </button>
                </div>
              ))}

              <div className='border-t pt-4'>
                <div className='flex justify-between text-lg font-semibold'>
                  <span>合計:</span>
                  <span>¥{totalPrice}</span>
                </div>
                <button
                  className='w-full bg-blue-500 text-white py-2 rounded mt-4'
                  onClick={() => {
                    // チェックアウト処理
                    console.log('Checkout:', items);
                  }}
                >
                  チェックアウト
                </button>
                <button
                  className='w-full bg-gray-300 py-2 rounded mt-2'
                  onClick={clearCart}
                >
                  カートを空にする
                </button>
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

レイアウトコンポーネントでのグローバルステート管理

App Router のレイアウトシステムを活用したグローバルステート管理の実装例です。

typescript// store/app-store.ts
import { create } from 'zustand';
import {
  persist,
  subscribeWithSelector,
} from 'zustand/middleware';

interface Notification {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  message: string;
  duration?: number;
}

interface AppState {
  // UI状態
  sidebarOpen: boolean;
  theme: 'light' | 'dark' | 'system';

  // 通知システム
  notifications: Notification[];

  // Loading状態
  isGlobalLoading: boolean;
  loadingMessage: string;

  // Actions
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  setTheme: (theme: 'light' | 'dark' | 'system') => void;

  addNotification: (
    notification: Omit<Notification, 'id'>
  ) => void;
  removeNotification: (id: string) => void;
  clearNotifications: () => void;

  setGlobalLoading: (
    loading: boolean,
    message?: string
  ) => void;
}

export const useAppStore = create<AppState>()(
  subscribeWithSelector(
    persist(
      (set, get) => ({
        sidebarOpen: false,
        theme: 'system',
        notifications: [],
        isGlobalLoading: false,
        loadingMessage: '',

        toggleSidebar: () =>
          set({ sidebarOpen: !get().sidebarOpen }),

        setSidebarOpen: (open) =>
          set({ sidebarOpen: open }),

        setTheme: (theme) => {
          set({ theme });

          // システムテーマの場合はOSの設定を監視
          if (theme === 'system') {
            const mediaQuery = window.matchMedia(
              '(prefers-color-scheme: dark)'
            );
            const updateTheme = () => {
              document.documentElement.className =
                mediaQuery.matches ? 'dark' : 'light';
            };

            updateTheme();
            mediaQuery.addEventListener(
              'change',
              updateTheme
            );
          } else {
            document.documentElement.className = theme;
          }
        },

        addNotification: (notification) => {
          const id = Math.random()
            .toString(36)
            .substr(2, 9);
          const newNotification = { ...notification, id };

          set({
            notifications: [
              ...get().notifications,
              newNotification,
            ],
          });

          // 自動削除タイマー
          if (notification.duration !== 0) {
            setTimeout(() => {
              get().removeNotification(id);
            }, notification.duration || 5000);
          }
        },

        removeNotification: (id) => {
          set({
            notifications: get().notifications.filter(
              (n) => n.id !== id
            ),
          });
        },

        clearNotifications: () =>
          set({ notifications: [] }),

        setGlobalLoading: (loading, message = '') => {
          set({
            isGlobalLoading: loading,
            loadingMessage: message,
          });
        },
      }),
      {
        name: 'app-state',
        partialize: (state) => ({
          theme: state.theme,
          sidebarOpen: state.sidebarOpen,
        }),
      }
    )
  )
);
typescript// app/layout.tsx (Root Layout)
import { AppInitializer } from './components/app-initializer';
import { NotificationContainer } from './components/notification-container';
import { GlobalLoading } from './components/global-loading';
import { Sidebar } from './components/sidebar';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <AppInitializer />
        <div className='flex min-h-screen'>
          <Sidebar />
          <main className='flex-1'>{children}</main>
        </div>
        <NotificationContainer />
        <GlobalLoading />
      </body>
    </html>
  );
}
typescript// app/components/app-initializer.tsx (Client Component)
'use client';

import { useEffect } from 'react';
import { useAppStore } from '../store/app-store';

export function AppInitializer() {
  const { setTheme } = useAppStore();

  useEffect(() => {
    // ハイドレーションの完了を待つ
    useAppStore.persist.rehydrate();

    // 初期テーマの設定
    const theme = useAppStore.getState().theme;
    setTheme(theme);
  }, [setTheme]);

  return null;
}
typescript// app/components/notification-container.tsx (Client Component)
'use client';

import { useAppStore } from '../store/app-store';

export function NotificationContainer() {
  const { notifications, removeNotification } =
    useAppStore();

  return (
    <div className='fixed top-4 right-4 z-50 space-y-2'>
      {notifications.map((notification) => (
        <div
          key={notification.id}
          className={`
            p-4 rounded-lg shadow-lg max-w-sm
            ${
              notification.type === 'success'
                ? 'bg-green-500 text-white'
                : ''
            }
            ${
              notification.type === 'error'
                ? 'bg-red-500 text-white'
                : ''
            }
            ${
              notification.type === 'warning'
                ? 'bg-yellow-500 text-black'
                : ''
            }
            ${
              notification.type === 'info'
                ? 'bg-blue-500 text-white'
                : ''
            }
          `}
        >
          <div className='flex items-center justify-between'>
            <p>{notification.message}</p>
            <button
              onClick={() =>
                removeNotification(notification.id)
              }
              className='ml-4 hover:opacity-75'
            >
              ✕
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

まとめ

Next.js App Router 時代における Zustand の活用方法について、詳しく解説してまいりました。Server Components と Client Components が共存する新しい環境では、従来のステート管理とは異なる課題と解決策があることをご理解いただけたでしょうか。

重要なポイントを振り返ってみましょう。まず、App Router の登場により、ステート管理の設計思想が根本的に変わりました。Server Components と Client Components の境界線を明確にし、それぞれの特性を活かした実装が求められるようになったのです。

課題解決のアプローチとして、Zustand の Provider 不要というシンプルな特性が、App Router 環境で特に威力を発揮することをお示しいたしました。'use client'ディレクティブの適切な配置により、必要最小限のクライアントサイドコードでステート管理を実現できるでしょう。

ハイドレーション問題の解決では、適切な初期化パターンとローディング状態の管理により、ユーザー体験を損なうことなくステート同期を実現する方法をご紹介しました。この手法により、SSR や RSC の恩恵を受けながら、リッチなインタラクションを提供できるのです。

実践的な実装例を通じて、Server Component でのデータフェッチと、Client Component でのステート管理を組み合わせたパターンをお示しいたしました。これらのパターンは、実際のプロジェクトで即座に活用できる実用的なものです。

今回ご紹介した手法を活用することで、App Router 環境でも従来と同様に、いえそれ以上に効率的なステート管理が可能になります。Server Components と Client Components の適切な役割分担により、パフォーマンスと開発効率の両立を実現していただければと思います。

Next.js の進化とともに、フロントエンド開発の可能性はさらに広がっています。今回の知識を基盤として、より良いユーザー体験を提供するアプリケーション開発にお役立てください。

関連リンク