React Suspense 実践ガイド:実務で使える非同期処理パターン
React 18以降、Suspenseは単なる概念実験から実務的なツールへと進化しました。本記事では、実際のプロジェクトで使えるSuspenseのパターンを、具体的なコードとともに紹介します。
1. React Suspenseとは:簡易解説
React Suspenseは、コンポーネントが「準備できていない」状態を親に伝え、その間に代替UIを表示する仕組みです。従来のローディング状態管理(useState + useEffect)との大きな違いは、データ取得ロジックをコンポーネント分離でき、宣言的に書けることです。
Suspenseの基本的な流れ:
- 子コンポーネントがPromiseをスロー
- Suspenseがキャッチして、fallback UIを表示
- Promiseが解決されたら、実際のUIを表示
これにより、ローディング状態の管理がコンポーネントツリーのレベルで統一されます。
2. 業務でのユースケース
Suspenseが活躍する場面:
- マイクロサービスアーキテクチャ:複数のAPI呼び出しを並列実行し、各部分が準備完了するまで待つ
- ページネーション機能:次ページのデータ先読み
- ダッシュボード画面:複数ウィジェットの非同期ロード
- ファイルアップロード機能:ファイル処理中の進捗表示
- 検索結果ページ:キーワード変更時の結果再取得
特に、データ取得ロジックとUI表示ロジックを完全に分離したい場合に有効です。
3. 実装コード:実務パターン集
3.1 基本的なリソース取得パターン
まず、データ取得を管理するリソースラッパーを作成します:
// src/lib/resource.ts
type Status = 'pending' | 'success' | 'error';
interface ResourceState<T> {
status: Status;
data?: T;
error?: Error;
}
class Resource<T> {
private promise: Promise<T>;
private state: ResourceState<T> = { status: 'pending' };
constructor(fetcher: () => Promise<T>) {
this.promise = fetcher()
.then((data) => {
this.state = { status: 'success', data };
return data;
})
.catch((error) => {
this.state = { status: 'error', error };
throw error;
});
}
read(): T {
if (this.state.status === 'success') {
return this.state.data!;
}
if (this.state.status === 'error') {
throw this.state.error;
}
throw this.promise;
}
}
export function createResource<T>(
fetcher: () => Promise<T>
): Resource<T> {
return new Resource(fetcher);
}
このResourceクラスは、Suspenseが期待するPromiseのスロー・解決パターンを実装しています。
3.2 ユーザー情報取得の実装例
実際のAPI呼び出しを含むコンポーネント:
// src/api/userApi.ts
export interface User {
id: number;
name: string;
email: string;
avatar: string;
}
export const fetchUser = (userId: number): Promise<User> => {
return fetch(`https://api.example.com/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error('User not found');
return res.json();
});
};
// src/resources/userResource.ts
import { createResource } from '@/lib/resource';
import { fetchUser, User } from '@/api/userApi';
export function getUserResource(userId: number) {
return createResource(() => fetchUser(userId));
}
リソースの作成を分離することで、複数の場所から同じロジックを再利用できます:
// src/components/UserProfile.tsx
import { Suspense } from 'react';
import { getUserResource } from '@/resources/userResource';
interface UserProfileProps {
userId: number;
}
function UserProfileContent({ resource }: { resource: ReturnType<typeof getUserResource> }) {
const user = resource.read();
return (
<div className=\"user-profile\">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
function UserProfileLoading() {
return (
<div className=\"skeleton\">
<div className=\"skeleton-avatar\" />
<div className=\"skeleton-text\" />
</div>
);
}
export function UserProfile({ userId }: UserProfileProps) {
const resource = getUserResource(userId);
return (
<Suspense fallback={<UserProfileLoading />}>
<UserProfileContent resource={resource} />
</Suspense>
);
}
3.3 複数リソースの並列取得パターン
ダッシュボードなど、複数のデータを同時に取得する場合:
// src/api/dashboardApi.ts
export interface DashboardData {
userStats: { totalUsers: number; activeUsers: number };
revenue: { monthly: number; yearly: number };
recentOrders: Array<{ id: number; amount: number; date: string }>;
}
export const fetchDashboardStats = (): Promise<DashboardData['userStats']> =>
fetch('https://api.example.com/stats/users').then((r) => r.json());
export const fetchDashboardRevenue = (): Promise<DashboardData['revenue']> =>
fetch('https://api.example.com/stats/revenue').then((r) => r.json());
export const fetchRecentOrders = (): Promise<DashboardData['recentOrders']> =>
fetch('https://api.example.com/orders/recent').then((r) => r.json());
// src/resources/dashboardResource.ts
import { createResource } from '@/lib/resource';
import {
fetchDashboardStats,
fetchDashboardRevenue,
fetchRecentOrders,
} from '@/api/dashboardApi';
export function getDashboardResources() {
return {
stats: createResource(fetchDashboardStats),
revenue: createResource(fetchDashboardRevenue),
orders: createResource(fetchRecentOrders),
};
}
各セクションを独立したSuspenseで管理することで、部分的なローディング表示が可能:
// src/components/Dashboard.tsx
import { Suspense } from 'react';
import { getDashboardResources } from '@/resources/dashboardResource';
function StatsSection({ statsResource }) {
const stats = statsResource.read();
return (
<div className=\"card\">
<h3>ユーザー統計</h3>
<p>全体: {stats.totalUsers}</p>
<p>アクティブ: {stats.activeUsers}</p>
</div>
);
}
function RevenueSection({ revenueResource }) {
const revenue = revenueResource.read();
return (
<div className=\"card\">
<h3>売上</h3>
<p>月次: ¥{revenue.monthly.toLocaleString()}</p>
<p>年次: ¥{revenue.yearly.toLocaleString()}</p>
</div>
);
}
function RecentOrdersSection({ ordersResource }) {
const orders = ordersResource.read();
return (
<div className=\"card\">
<h3>最近の注文</h3>
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.date}: ¥{order.amount.toLocaleString()}
</li>
))}
</ul>
</div>
);
}
function LoadingCard() {
return <div className=\"card skeleton\" />;
}
export function Dashboard() {
const resources = getDashboardResources();
return (
<div className=\"dashboard\">
<h1>ダッシュボード</h1>
<div className=\"grid\">
<Suspense fallback={<LoadingCard />}>
<StatsSection statsResource={resources.stats} />
</Suspense>
<Suspense fallback={<LoadingCard />}>
<RevenueSection revenueResource={resources.revenue} />
</Suspense>
<Suspense fallback={<LoadingCard />}>
<RecentOrdersSection ordersResource={resources.orders} />
</Suspense>
</div>
</div>
);
}
3.4 エラーハンドリングとErrorBoundary統合
実務では、Suspenseだけでなくエラーハンドリングも重要です:
// src/components/ErrorBoundary.tsx
import React, { ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: (error: Error, retry: () => void) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught:', error, errorInfo);
}
retry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
return (
this.props.fallback?.(this.state.error, this.retry) || (
<div className=\"error-container\">
<h2>エラーが発生しました</h2>
<p>{this.state.error.message}</p>
<button onClick={this.retry}>もう一度試す</button>
</div>
)
);
}
return this.props.children;
}
}
SuspenseとErrorBoundaryを組み合わせた実装:
// src/components/SafeUserProfile.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from './ErrorBoundary';
import { UserProfile } from './UserProfile';
export function SafeUserProfile({ userId }: { userId: number }) {
return (
<ErrorBoundary
fallback={(error, retry) => (
<div className=\"error-card\">
<h3>プロフィール読み込みエラー</h3>
<p>{error.message}</p>
<button onClick={retry}>再読み込み</button>
</div>
)}
>
<UserProfile userId={userId} />
</ErrorBoundary>
);
}
3.5 キャッシング戦略の実装
何度も同じデータを取得しないようにキャッシュする実装:
// src/lib/cachedResource.ts
import { createResource, Resource } from './resource';
class CachedResourceManager<T> {
private cache = new Map<string, Resource<T>>();
private fetcher: (key: string) => Promise<T>;
private ttl: number; // TTL in milliseconds
private timestamps = new Map<string, number>();
constructor(fetcher: (key: string) => Promise<T>, ttl: number = 5 * 60 * 1000) {
this.fetcher = fetcher;
this.ttl = ttl;
}
get(key: string): Resource<T> {
const now = Date.now();
const lastTime = this.timestamps.get(key) || 0;
// キャッシュが有効期限内かチェック
if (this.cache.has(key) && now - lastTime < this.ttl) {
return this.cache.get(key)!;
}
// 新しいリソースを作成
const resource = createResource(() => this.fetcher(key));
this.cache.set(key, resource);
this.timestamps.set(key, now);
return resource;
}
invalidate(key: string): void {
this.cache.delete(key);
this.timestamps.delete(key);
}
invalidateAll(): void {
this.cache.clear();
this.timestamps.clear();
}
}
export function createCachedResourceManager<T>(
fetcher: (key: string) => Promise<T>,
ttl?: number
) {
return new CachedResourceManager(fetcher, ttl);
}
キャッシュマネージャーの使用例:
// src/resources/cachedUserResource.ts
import { createCachedResourceManager } from '@/lib/cachedResource';
import { fetchUser } from '@/api/userApi';
export const userResourceManager = createCachedResourceManager(
(userId) => fetchUser(parseInt(userId, 10)),
10 * 60 * 1000 // 10分のキャッシュ
);
// src/components/UserCard.tsx
import { Suspense } from 'react';
import { userResourceManager } from '@/resources/cachedUserResource';
export function UserCard({ userId }: { userId: number }) {
const resource = userResourceManager.get(userId.toString());
return (
<Suspense fallback={<div>読み込み中...</div>}>
<UserCardContent resource={resource} />
</Suspense>
);
}
function UserCardContent({ resource }) {
const user = resource.read();
return <div>{user.name}</div>;
}
4. よくある応用パターン
4.1 ページネーション対応
次ページのデータを先読みするパターン:
// src/components/PaginatedList.tsx
import { Suspense, useState } from 'react';
import { createCachedResourceManager } from '@/lib/cachedResource';
interface ListItem {
id: number;
title: string;
}
const listResourceManager = createCachedResourceManager(
async (pageKey: string) => {
const [page, perPage] = pageKey.split('_').map(Number);
const res = await fetch(
`https://api.example.com/items?page=${page}&perPage=${perPage}`
);
return res.json() as Promise<ListItem[]>;
}
);
export function PaginatedList() {
const [page, setPage] = useState(1);
const perPage = 10;
const pageKey = `${page}_${perPage}`;
const nextPageKey = `${page + 1}_${perPage}`;
// 次ページを先読み
const currentResource = listResourceManager.get(pageKey);
const nextResource = listResourceManager.get(nextPageKey);
return (
<div>
<Suspense fallback={<div>読み込み中...</div>}>
<ListContent resource={currentResource} />
</Suspense>
<div className=\"pagination\">
<button
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
>
前へ
</button>
<span>ページ {page}</span>
<button onClick={() => setPage((p) => p + 1)}>
次へ
</button>
</div>
</div>
);
}
function ListContent({ resource }: { resource: any }) {
const items = resource.read();
return (
<ul>
{items.map((item: ListItem) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
4.2 検索結果のリアルタイム更新
デバウンス付きの検索パターン:
// src/hooks/useSearchResource.ts
import { useEffect, useRef } from 'react';
import { createCachedResourceManager } from '@/lib/cachedResource';
const searchResourceManager = createCachedResourceManager(
async (query: string) => {
if (!query.trim()) return [];
const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`);
return res.json();
},
60 * 1000 // 1分キャッシュ
);
export function useSearchResource(query: string, debounceMs: number = 300) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastQueryRef = useRef('');
// デバウンス処理
if (query !== lastQueryRef.current) {
lastQueryRef.current = query;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
return timeoutRef.current = setTimeout(() => {
// リソースを取得
}, debounceMs);
}
return searchResourceManager.get(query);
}
4.3 リアルタイムデータ更新(ポーリング)
定期的にデータを再取得する実装:
// src/lib/polledResource.ts
import { createResource, Resource } from './resource';
class PolledResourceManager<T> {
private resources = new Map<string, Resource<T>>();
private intervals = new Map<string, NodeJS.Timeout>();
private fetcher: (key: string) => Promise<T>;
private pollInterval: number;
constructor(fetcher: (key: string) => Promise<T>, pollInterval: number = 5000) {
this.fetcher = fetcher;
this.pollInterval = pollInterval;
}
get(key: string, autoPoll: boolean = true): Resource<T> {
if (!this.resources.has(key)) {
const resource = createResource(() => this.fetcher(key));
this.resources.set(key, resource);
if (autoPoll) {
this.startPolling(key);
}
}
return this.resources.get(key)!;
}
private startPolling(key: string): void {
const interval = setInterval(() => {
const resource = createResource(() => this.fetcher(key));
this.resources.set(key, resource);
}, this.pollInterval);
this.intervals.set(key, interval);
}
stopPolling(key: string): void {
const interval = this.intervals.get(key);
if (interval) {
clearInterval(interval);
this.intervals.delete(key);
}
}
destroy(): void {
this.intervals.forEach((interval) => clearInterval(interval));
this.intervals.clear();
this.resources.clear();
}
}
export function createPolledResourceManager<T>(
fetcher: (key: string) => Promise<T>,
pollInterval?: number
) {
return new PolledResourceManager(fetcher, pollInterval);
}
5. 実装時の注意点
5.1 メモリリークとクリーンアップ
リソースマネージャーは適切にクリーンアップする必要があります。ページ遷移時やコンポーネントアンマウント時に、ポーリングを停止しましょう:
// src/components/NotificationCenter.tsx
import { useEffect } from 'react';
import { createPolledResourceManager } from '@/lib/polledResource';
const notificationManager = createPolledResourceManager(
async () => {
const res = await fetch('https://api.example.com/notifications');
return res.json();
},
3000 // 3秒ごと
);
export function NotificationCenter() {
useEffect(() => {
const resource = notificationManager.get('current', true);
// アンマウント時にポーリングを停止
return () => {
notificationManager.stopPolling('current');
};
}, []);
// 実装...
}
5.2 型安全性の確保
TypeScriptを使う際は、ジェネリック型を活用して型安全性を高めます:
// src/types/api.ts
export interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
perPage: number;
}
// src/api/typed-api.ts
export async function typedFetch<T>(
url: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`API Error: ${res.status}`);
}
const data = await res.json();
return data as T;
}
5.3 バウンダリー層の設計
Suspenseが深すぎるコンポーネントツリーに埋まらないよう、バウンダリーは計画的に配置:
// ❌ 避けるべき:深すぎるバウンダリー
export function Page() {
return (
<Suspense fallback={<Spinner />}>
<Header />
<Sidebar />
<Main>
<Section1>
<Card1 /> {/* ここのデータ遅延が全体に影響 */}
</Section1>
</Main>
</Suspense>
);
}
// ✅ 推奨:各セクションで独立したバウンダリー
export function Page() {
return (
<>
<Header />
<Sidebar />
<Main>
<Suspense fallback={<Section1Skeleton />}>
<Section1>
<Card1 />
</Section1>
</Suspense>
</Main>
</>
);
}
5.4 ネットワークリトライ戦略
失敗時の自動リトライを実装すると、ユーザー体験が向上:
// src/lib/retryableResource.ts
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i < maxRetries; i++) {
try {
return await fetcher();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
// 指数バックオフ
const delay = delayMs * Math.pow(2, i);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
6. パフォーマンス最適化のコツ
複数のSuspenseを使う際、以下に注意すると更に最適化できます:
- Waterfallを避ける:A→Bの順序で読み込むのではなく、ABを並列実行
- バウンダリー粒度:細粒度すぎると管理が大変。適度な粒度を探す
- Image/Script遅延読み込み:Next.jsなどのフレームワークと組み合わせる
- キャッシュ戦略:API呼び出しを最小化する設計
7. まとめ
React Suspenseは、適切に使うことで非同期処理のコードを大幅に簡潔にできます。実務での活用ポイント:
- リソースクラスでPromiseの管理を一元化する
- 複数データ取得時は部分的なバウンダリーで柔軟に対応
- キャッシュとポーリングを組み合わせたマネージャーで再利用性を高める
- エラーハンドリングはErrorBoundaryと統合
- メモリリークやネットワークリトライに気をつける
Suspenseはまだ進化中の機能ですが、適切に理解して使うことで、React開発の生産性が大幅に向上します。本記事のコード例を参考に、プロジェクトに合わせてカスタマイズしてみてください。

