Documentation Next.js

はじめに

Next.js App Routerでは、Server ComponentsとClient Componentsを効果的に組み合わせることで、パフォーマンスと開発体験を両立できます。この記事では、実践的なコンポーネント設計パターンを解説します。

Server Components vs Client Components

使い分けの基準

用途Server ComponentsClient Components
データ取得✅ 推奨⚠️ 可能だが非推奨
バックエンドリソース直接アクセス✅ 可能❌ 不可
センシティブな情報使用✅ 安全❌ 危険
イベントハンドラ❌ 不可✅ 必須
useState/useEffect❌ 不可✅ 必須
ブラウザAPI❌ 不可✅ 必須

Compositionパターン

サーバーコンポーネント内でクライアントコンポーネントを合成するパターンです。

// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { DashboardHeader } from '@/components/DashboardHeader';
import { UserStats } from '@/components/UserStats';
import { InteractiveChart } from '@/components/InteractiveChart';
import { LoadingSpinner } from '@/components/LoadingSpinner';

export default async function DashboardPage() {
  // サーバーサイドでデータ取得
  const user = await getUser();
  const stats = await getStats();

  return (
    <div className="dashboard">
      {/* 静的なヘッダー(Server Component) */}
      <DashboardHeader user={user} />

      {/* 統計情報(Server Component) */}
      <Suspense fallback={<LoadingSpinner />}>
        <UserStats stats={stats} />
      </Suspense>

      {/* インタラクティブなチャート(Client Component) */}
      <InteractiveChart initialData={stats.chartData} />
    </div>
  );
}
// components/InteractiveChart.tsx
'use client';

import { useState } from 'react';
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';

interface ChartData {
  date: string;
  value: number;
}

export function InteractiveChart({ initialData }: { initialData: ChartData[] }) {
  const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
  const [data, setData] = useState(initialData);

  const handleTimeRangeChange = async (range: typeof timeRange) => {
    setTimeRange(range);
    const response = await fetch(`/api/stats?range=${range}`);
    const newData = await response.json();
    setData(newData);
  };

  return (
    <div className="chart-container">
      <div className="controls">
        {(['week', 'month', 'year'] as const).map((range) => (
          <button
            key={range}
            onClick={() => handleTimeRangeChange(range)}
            className={timeRange === range ? 'active' : ''}
          >
            {range}
          </button>
        ))}
      </div>
      <LineChart width={600} height={300} data={data}>
        <XAxis dataKey="date" />
        <YAxis />
        <Tooltip />
        <Line type="monotone" dataKey="value" stroke="#8884d8" />
      </LineChart>
    </div>
  );
}

Container/Presentationalパターン

ロジックとUIを分離するパターンです。

// containers/UserListContainer.tsx (Server Component)
import { UserList } from '@/components/UserList';
import { getUsers } from '@/lib/api';

export async function UserListContainer() {
  const users = await getUsers();

  return <UserList users={users} />;
}
// components/UserList.tsx (Server Component)
interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

export function UserList({ users }: { users: User[] }) {
  return (
    <ul className="user-list">
      {users.map((user) => (
        <li key={user.id} className="user-item">
          <img src={user.avatar} alt={user.name} />
          <div>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

Compound Componentパターン

関連するコンポーネントをグループ化するパターンです。

// components/Card/index.tsx
import { CardRoot } from './CardRoot';
import { CardHeader } from './CardHeader';
import { CardBody } from './CardBody';
import { CardFooter } from './CardFooter';

export const Card = {
  Root: CardRoot,
  Header: CardHeader,
  Body: CardBody,
  Footer: CardFooter,
};

// CardRoot.tsx
export function CardRoot({ children }: { children: React.ReactNode }) {
  return <div className="card">{children}</div>;
}

// CardHeader.tsx
export function CardHeader({
  title,
  subtitle,
}: {
  title: string;
  subtitle?: string;
}) {
  return (
    <div className="card-header">
      <h3>{title}</h3>
      {subtitle && <p>{subtitle}</p>}
    </div>
  );
}

// CardBody.tsx
export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>;
}

// CardFooter.tsx
export function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="card-footer">{children}</div>;
}
// 使用例
import { Card } from '@/components/Card';

export function ProductCard({ product }: { product: Product }) {
  return (
    <Card.Root>
      <Card.Header title={product.name} subtitle={product.category} />
      <Card.Body>
        <p>{product.description}</p>
        <span className="price">${product.price}</span>
      </Card.Body>
      <Card.Footer>
        <AddToCartButton productId={product.id} />
      </Card.Footer>
    </Card.Root>
  );
}

Render Propsパターン

柔軟なレンダリングを可能にするパターンです。

// components/DataFetcher.tsx
'use client';

import { useState, useEffect, ReactNode } from 'react';

interface DataFetcherProps<T> {
  url: string;
  children: (data: T | null, loading: boolean, error: Error | null) => ReactNode;
}

export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error('Fetch failed');
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return <>{children(data, loading, error)}</>;
}
// 使用例
<DataFetcher<User[]> url="/api/users">
  {(users, loading, error) => {
    if (loading) return <LoadingSpinner />;
    if (error) return <ErrorMessage error={error} />;
    if (!users) return null;

    return <UserList users={users} />;
  }}
</DataFetcher>

HOC(Higher-Order Component)パターン

コンポーネントに機能を追加するパターンです。

// hoc/withAuth.tsx
'use client';

import { useSession } from 'next-auth/react';
import { redirect } from 'next/navigation';
import { ComponentType } from 'react';

export function withAuth<P extends object>(
  WrappedComponent: ComponentType<P>
) {
  return function AuthenticatedComponent(props: P) {
    const { data: session, status } = useSession();

    if (status === 'loading') {
      return <div>Loading...</div>;
    }

    if (!session) {
      redirect('/login');
    }

    return <WrappedComponent {...props} />;
  };
}
// 使用例
'use client';

import { withAuth } from '@/hoc/withAuth';

function ProfilePage() {
  return <div>Protected Profile Content</div>;
}

export default withAuth(ProfilePage);

Custom Hooksパターン

ロジックを再利用可能なフックに抽出するパターンです。

// hooks/useLocalStorage.ts
'use client';

import { useState, useEffect } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(initialValue);

  useEffect(() => {
    try {
      const item = window.localStorage.getItem(key);
      if (item) {
        setStoredValue(JSON.parse(item));
      }
    } catch (error) {
      console.error('Error reading localStorage:', error);
    }
  }, [key]);

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error('Error setting localStorage:', error);
    }
  };

  return [storedValue, setValue];
}
// hooks/usePagination.ts
'use client';

import { useState, useMemo } from 'react';

interface UsePaginationProps<T> {
  data: T[];
  itemsPerPage: number;
}

export function usePagination<T>({ data, itemsPerPage }: UsePaginationProps<T>) {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(data.length / itemsPerPage);

  const paginatedData = useMemo(() => {
    const start = (currentPage - 1) * itemsPerPage;
    return data.slice(start, start + itemsPerPage);
  }, [data, currentPage, itemsPerPage]);

  const goToPage = (page: number) => {
    setCurrentPage(Math.max(1, Math.min(page, totalPages)));
  };

  return {
    currentPage,
    totalPages,
    paginatedData,
    goToPage,
    nextPage: () => goToPage(currentPage + 1),
    prevPage: () => goToPage(currentPage - 1),
    hasNextPage: currentPage < totalPages,
    hasPrevPage: currentPage > 1,
  };
}

フォルダ構成のベストプラクティス

src/
├── app/                      # App Router
│   ├── (auth)/               # 認証関連グループ
│   │   ├── login/
│   │   └── register/
│   ├── (main)/               # メインコンテンツグループ
│   │   ├── dashboard/
│   │   └── settings/
│   └── api/                  # API Routes

├── components/
│   ├── ui/                   # 汎用UIコンポーネント
│   │   ├── Button/
│   │   ├── Card/
│   │   └── Modal/
│   ├── features/             # 機能別コンポーネント
│   │   ├── auth/
│   │   ├── dashboard/
│   │   └── settings/
│   └── layouts/              # レイアウトコンポーネント
│       ├── Header/
│       ├── Footer/
│       └── Sidebar/

├── hooks/                    # カスタムフック
│   ├── useAuth.ts
│   ├── useLocalStorage.ts
│   └── usePagination.ts

├── lib/                      # ユーティリティ
│   ├── api.ts
│   ├── utils.ts
│   └── validators.ts

└── types/                    # 型定義
    ├── user.ts
    └── product.ts

パフォーマンス最適化

クライアントコンポーネントを末端に配置

// ❌ 悪い例:ルートでuse clientを使用
// app/page.tsx
'use client';

import { useState } from 'react';

export default function Page() {
  const [count, setCount] = useState(0);
  // 全体がクライアントコンポーネントになる
  return (
    <div>
      <Header /> {/* これもクライアントになる */}
      <Content /> {/* これもクライアントになる */}
      <button onClick={() => setCount(count + 1)}>{count}</button>
    </div>
  );
}

// ✅ 良い例:必要な部分だけクライアントコンポーネント
// app/page.tsx (Server Component)
import { Header } from '@/components/Header';
import { Content } from '@/components/Content';
import { Counter } from '@/components/Counter';

export default function Page() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <Content /> {/* Server Component */}
      <Counter /> {/* Client Component - 必要な部分だけ */}
    </div>
  );
}

// components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

まとめ

効果的なコンポーネント設計のポイントをまとめます。

  • Server Componentsをデフォルトに: データ取得と静的コンテンツはサーバーで
  • Client Componentsは必要最小限: インタラクティブな部分のみ
  • Compositionで合成: 小さなコンポーネントを組み合わせる
  • Custom Hooksでロジック再利用: 状態管理ロジックを抽出
  • 適切なフォルダ構成: 機能別・役割別に整理

参考文献

円