はじめに
Next.js App Routerでは、Server ComponentsとClient Componentsを効果的に組み合わせることで、パフォーマンスと開発体験を両立できます。この記事では、実践的なコンポーネント設計パターンを解説します。
Server Components vs Client Components
使い分けの基準
| 用途 | Server Components | Client 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でロジック再利用: 状態管理ロジックを抽出
- 適切なフォルダ構成: 機能別・役割別に整理