React Suspense 実践ガイド:非同期データ取得とローディング状態の実装パターン

React / Next.js

React Suspense 実践ガイド:実務で使える非同期処理パターン

React 18以降、Suspenseは単なる概念実験から実務的なツールへと進化しました。本記事では、実際のプロジェクトで使えるSuspenseのパターンを、具体的なコードとともに紹介します。

1. React Suspenseとは:簡易解説

React Suspenseは、コンポーネントが「準備できていない」状態を親に伝え、その間に代替UIを表示する仕組みです。従来のローディング状態管理(useState + useEffect)との大きな違いは、データ取得ロジックをコンポーネント分離でき、宣言的に書けることです。

Suspenseの基本的な流れ:

  1. 子コンポーネントがPromiseをスロー
  2. Suspenseがキャッチして、fallback UIを表示
  3. 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開発の生産性が大幅に向上します。本記事のコード例を参考に、プロジェクトに合わせてカスタマイズしてみてください。

タイトルとURLをコピーしました