React Server Components×TypeScriptのデータフェッチ設計パターン【個人開発入門】
結論:RSCのデータフェッチはこの3パターンを押さえれば十分
React Server Components(RSC)のデータフェッチで悩む個人開発者に伝えたいことは一つです。
「どこで fetch するか」ではなく「誰がデータの責任を持つか」を設計の軸にすると、型安全でメンテしやすいコードになります。
具体的には次の3パターンを使い分けるだけで、個人開発SaaSの8割のユースケースはカバーできます。
| パターン | 使いどころ | 特徴 |
|---|---|---|
| ① Page直接fetch | ページ固有の単純なデータ | 最もシンプル。まずここから |
| ② Componentごとfetch | コンポーネントが自律的にデータを持つ | 並列取得・再利用性に優れる |
| ③ Repository層 + 型ガード | DB/APIアクセスを抽象化 | 中規模以上・テスト重視向け |
以降で各パターンを実際のコードとともに解説します。
前提:App Routerの基本動作を確認する
Next.js 13以降のApp Routerでは、app/ディレクトリ以下のコンポーネントはデフォルトでServer Componentになります。
// app/dashboard/page.tsx
// この関数はサーバーサイドでのみ実行される
export default async function DashboardPage() {
const data = await fetch('https://api.example.com/stats');
// ...
}
Server Componentでできること / できないこと
| できること | できないこと |
|---|---|
| async/await で直接 fetch | useState / useEffect |
| サーバー環境変数の参照 | ブラウザAPIの利用 |
| DBへの直接アクセス | onClick などのイベントハンドラ |
| シークレットキーの利用 |
この制約を把握したうえで、3パターンを見ていきましょう。
パターン①:Page直接fetch(入門)
最もシンプルなパターンです。ページコンポーネントが直接データを取得します。
// app/projects/page.tsx
import { ProjectCard } from '@/components/ProjectCard';
// 型定義
type Project = {
id: string;
name: string;
status: 'active' | 'archived';
createdAt: string;
};
type ApiResponse = {
projects: Project[];
};
async function getProjects(): Promise<Project[]> {
const res = await fetch(`${process.env.API_BASE_URL}/projects`, {
// Next.jsのキャッシュ戦略を指定
next: { revalidate: 60 }, // 60秒でISR
});
if (!res.ok) {
// Next.js 13+のエラーバウンダリに委ねる
throw new Error('プロジェクトの取得に失敗しました');
}
const data: ApiResponse = await res.json();
return data.projects;
}
export default async function ProjectsPage() {
const projects = await getProjects();
return (
<main>
<h1>プロジェクト一覧</h1>
<ul>
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</ul>
</main>
);
}
// components/ProjectCard.tsx
// Propsの型はPageから渡されるため自動的に型安全
type Props = {
project: {
id: string;
name: string;
status: 'active' | 'archived';
};
};
export function ProjectCard({ project }: Props) {
return (
<li>
<span>{project.name}</span>
<span>{project.status === 'active' ? '稼働中' : 'アーカイブ'}</span>
</li>
);
}
ポイント: getProjects() の返り値を Promise<Project[]> と明示することで、コンポーネント側は完全な型推論の恩恵を受けられます。
パターン②:Componentごとfetch(並列取得)
ページが複数の独立したデータを必要とする場合、コンポーネントごとにfetchを分散させます。Next.jsの fetch はリクエストを自動的に重複排除(dedupe)するため、同じURLへの呼び出しはキャッシュされます。
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StatsWidget } from '@/components/StatsWidget';
import { RecentActivity } from '@/components/RecentActivity';
export default function DashboardPage() {
return (
<main>
<h1>ダッシュボード</h1>
{/* Suspenseで包むことでストリーミングレンダリングが有効に */}
<Suspense fallback={<p>統計を読み込み中...</p>}>
<StatsWidget />
</Suspense>
<Suspense fallback={<p>アクティビティを読み込み中...</p>}>
<RecentActivity />
</Suspense>
</main>
);
}
// components/StatsWidget.tsx
type Stats = {
totalRevenue: number;
activeUsers: number;
churnRate: number;
};
async function getStats(): Promise<Stats> {
const res = await fetch(`${process.env.API_BASE_URL}/stats`, {
next: { revalidate: 300 },
});
if (!res.ok) throw new Error('統計の取得に失敗しました');
return res.json();
}
// Server Componentなのでasync関数にできる
export async function StatsWidget() {
const stats = await getStats();
return (
<div>
<p>売上合計: ¥{stats.totalRevenue.toLocaleString()}</p>
<p>アクティブユーザー: {stats.activeUsers}人</p>
</div>
);
}
DashboardPage 自体は async でなくなり、StatsWidget と RecentActivity が並列でデータ取得を開始します。Suspense により、遅いコンポーネントが速いコンポーネントのレンダリングをブロックしません。
パターン③:Repository層 + 型ガード(実践)
個人開発SaaSが成長してきたら、データアクセスをRepository層として抽象化します。これにより、テスト・モック・DB切り替えが容易になります。
// lib/repositories/projectRepository.ts
import { z } from 'zod'; // zodで実行時バリデーション
// Zodスキーマ定義(型定義とバリデーションを一元化)
const ProjectSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
status: z.enum(['active', 'archived']),
ownerId: z.string().uuid(),
createdAt: z.string().datetime(),
});
// スキーマから型を生成(DRY原則)
export type Project = z.infer<typeof ProjectSchema>;
const ProjectListSchema = z.array(ProjectSchema);
export type ProjectRepository = {
findAll: (ownerId: string) => Promise<Project[]>;
findById: (id: string) => Promise<Project | null>;
};
// 実装
export const createProjectRepository = (): ProjectRepository => ({
async findAll(ownerId) {
const res = await fetch(
`${process.env.API_BASE_URL}/projects?ownerId=${ownerId}`,
{ next: { tags: [`projects-${ownerId}`] } } // On-demand revalidation用
);
if (!res.ok) throw new Error('Failed to fetch projects');
const raw = await res.json();
// Zodで実行時バリデーション → 型安全を保証
const result = ProjectListSchema.safeParse(raw);
if (!result.success) {
console.error('APIレスポンスの型が不正です:', result.error);
throw new Error('Invalid API response');
}
return result.data;
},
async findById(id) {
const res = await fetch(`${process.env.API_BASE_URL}/projects/${id}`, {
next: { tags: [`project-${id}`] },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error('Failed to fetch project');
const raw = await res.json();
return ProjectSchema.parse(raw);
},
});
// app/projects/[id]/page.tsx
import { createProjectRepository } from '@/lib/repositories/projectRepository';
import { notFound } from 'next/navigation';
type Props = {
params: { id: string };
};
export default async function ProjectDetailPage({ params }: Props) {
const repo = createProjectRepository();
const project = await repo.findById(params.id);
// nullの場合はNext.jsの404ページへ
if (!project) notFound();
// ここでは project が Project 型であることが型システムに保証されている
return (
<div>
<h1>{project.name}</h1>
<p>ステータス: {project.status}</p>
<p>作成日: {new Date(project.createdAt).toLocaleDateString('ja-JP')}</p>
</div>
);
}
Zodを使う最大のメリットは、JSON.parse() 後の any 型地獄を根絶できることです。外部APIのレスポンスは実行時まで型が保証されないため、Zodによる境界での検証が型安全設計の要になります。
エラーハンドリングの設計
RSCのエラーハンドリングは error.tsx で一元管理できます。
// app/projects/error.tsx
'use client'; // Error Boundaryはクライアントコンポーネント必須
import { useEffect } from 'react';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ProjectsError({ error, reset }: Props) {
useEffect(() => {
// エラー監視サービス(Sentry等)へ送信
console.error(error);
}, [error]);
return (
<div>
<h2>データの取得に失敗しました</h2>
<p>{error.message}</p>
<button onClick={reset}>再試行</button>
</div>
);
}
Server Component内で throw new Error() すると、最も近い error.tsx がキャッチします。ページ全体を壊さずに部分的なエラー表示が可能です。
3パターンの選び方まとめ
個人開発の規模・フェーズで選ぶ
立ち上げ期(〜1,000 DAU)
→ パターン①:Page直接fetch
→ シンプルさ優先、速く動くものを作る
成長期(〜10,000 DAU)
→ パターン②:Componentごとfetch
→ UX改善のためストリーミング・並列化を導入
安定期・チーム化(10,000 DAU〜)
→ パターン③:Repository層 + Zod
→ テスト・保守性・型安全を本格整備
最初からパターン③を使う必要はありません。プロダクトのフェーズに合わせて段階的に移行するのが現実的な個人開発の戦略です。
まとめ
- RSCのデータフェッチは「誰がデータの責任を持つか」で設計する
- パターン①(Page直接fetch)はシンプルで立ち上げ期に最適
- パターン②(Component分散fetch + Suspense)は並列取得とUX向上に有効
- パターン③(Repository + Zod)はスケールしても壊れない型安全設計の基盤
- エラーハンドリングは
error.tsxに集約し、Server Component側では潔くthrowする
TypeScriptの型安全とRSCの設計をセットで学ぶことで、「動くけど怖くて触れないコード」から卒業できます。まずはパターン①から実際に手を動かして試してみてください。