React Suspenseの実務活用ガイド|非同期処理とデータ取得を効率的に管理する

React / Next.js

React Suspenseの実務活用ガイド|非同期処理とデータ取得を効率的に管理する

React 16.6で導入されたSuspenseは、非同期処理とコンポーネントのレンダリングを宣言的に扱うための機能です。まだ採用に躊躇している開発者も多いと思いますが、実務で活用することで、ローディング状態の管理が劇的に簡潔になります。この記事では、教科書的な説明ではなく、実際のプロジェクトで使えるパターンを中心に解説します。

Suspenseの基本的な仕組み

Suspenseは、コンポーネントが準備完了するまで親コンポーネントのレンダリングを「一時停止」し、その間にフォールバックUIを表示する機能です。従来のローディング状態管理(useStateやisLoadingフラグ)とは異なり、データ取得のための条件分岐をコンポーネント自体に書く必要がなくなります。

基本的な構文は以下の通りです:

import { Suspense } from 'react';

export function App() {
  return (
    <Suspense fallback={<Loading />}>
      <UserProfile />
    </Suspense>
  );
}

function UserProfile() {
  // ここでPromiseをthrowする
  // 準備が完了するまでこのコンポーネントはレンダリングされない
  return <div>ユーザー情報</div>;
}

Suspenseの重要なポイントは、データ取得と並行して、コンポーネント構造を入れ子にして管理できることです。これにより、画面全体がブロッキングされず、段階的にUIが完成していくパターンが実現できます。

実務でのユースケース

シナリオ1: APIレスポンスを待つ必要があるページ遷移

ECサイトの商品詳細ページでは、URLパラメータから商品IDを取得した直後に、APIから商品情報を引き取る必要があります。従来のやり方では、useEffectでフェッチし、ローディング状態をセットして、条件分岐でUIを切り替えるという流れでした。

// ❌ 従来のやり方(Suspenseなし)
import { useEffect, useState } from 'react';

export function ProductDetail({ productId }: { productId: string }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [productId]);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラーが発生しました</div>;
  if (!product) return null;

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}円</p>
    </div>
  );
}

Suspenseを使うと、このコンポーネント内での状態管理が不要になります:

// ✅ Suspenseを使ったやり方
import { Suspense, use } from 'react';

// データキャッシュの仕組み
const productCache = new Map();

function fetchProduct(productId: string) {
  const key = `product-${productId}`;
  
  if (!productCache.has(key)) {
    const promise = fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .catch(err => {
        productCache.delete(key);
        throw err;
      });
    productCache.set(key, promise);
  }
  
  return productCache.get(key);
}

function ProductContent({ productId }: { productId: string }) {
  const product = use(fetchProduct(productId));
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}円</p>
    </div>
  );
}

export function ProductDetail({ productId }: { productId: string }) {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <ProductContent productId={productId} />
    </Suspense>
  );
}

シナリオ2: 複数のAPI呼び出しを並列実行

ユーザープロフィールページで、ユーザー情報、フォロワーリスト、投稿履歴を同時に取得する必要があるケースは実務では頻出です。従来のやり方では複数のuseStateとuseEffectの組み合わせで、コンポーネントが煩雑になりがちです。

// Suspenseを使った並列データ取得
const resourceCache = new Map();

function fetchResource<T>(key: string, fetcher: () => Promise<T>): T {
  if (!resourceCache.has(key)) {
    resourceCache.set(key, fetcher().catch(err => {
      resourceCache.delete(key);
      throw err;
    }));
  }
  
  const resource = resourceCache.get(key);
  
  if (resource instanceof Promise) {
    throw resource;
  }
  
  if (resource.status === 'error') {
    throw resource.error;
  }
  
  return resource.data;
}

function UserInfo({ userId }: { userId: string }) {
  const user = fetchResource(`user-${userId}`, () =>
    fetch(`/api/users/${userId}`).then(r => r.json())
  );
  
  return <div><h2>{user.name}</h2></div>;
}

function UserFollowers({ userId }: { userId: string }) {
  const followers = fetchResource(`followers-${userId}`, () =>
    fetch(`/api/users/${userId}/followers`).then(r => r.json())
  );
  
  return (
    <div>
      <h3>フォロワー: {followers.length}人</h3>
    </div>
  );
}

function UserPosts({ userId }: { userId: string }) {
  const posts = fetchResource(`posts-${userId}`, () =>
    fetch(`/api/users/${userId}/posts`).then(r => r.json())
  );
  
  return (
    <div>
      <h3>投稿数: {posts.length}</h3>
    </div>
  );
}

export function UserProfile({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<div>プロフィール読み込み中...<div>}>
      <UserInfo userId={userId} />
      <Suspense fallback={<div>フォロワー情報読み込み中...</div>}>
        <UserFollowers userId={userId} />
      </Suspense>
      <Suspense fallback={<div>投稿情報読み込み中...</div>}>
        <UserPosts userId={userId} />
      </Suspense>
    </Suspense>
  );
}

このアプローチの利点は、各APIの読み込みが独立しており、ユーザー情報が先に完成すれば、それが即座に画面に反映されるということです。ネットワークが遅い場合でも、ユーザーが見られる情報から段階的に表示できます。

実装コード|実務で使えるヘルパー関数

毎回キャッシュ処理を書くのは大変なので、実務では以下のようなヘルパー関数を作っておくと重宝します:

// suspenseHelper.ts
interface CachedPromise<T> {
  status: 'pending' | 'success' | 'error';
  data?: T;
  error?: Error;
  promise?: Promise<T>;
}

const resourceMap = new Map<string, CachedPromise<any>>();

export function wrapPromise<T>(promise: Promise<T>): () => T {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let result: T;
  let error: Error;

  const suspended = promise
    .then(r => {
      status = 'success';
      result = r;
    })
    .catch(e => {
      status = 'error';
      error = e;
    });

  return () => {
    if (status === 'pending') {
      throw suspended;
    } else if (status === 'error') {
      throw error;
    }
    return result;
  };
}

export function createResource<T>(
  key: string,
  fetcher: () => Promise<T>
): () => T {
  if (!resourceMap.has(key)) {
    resourceMap.set(key, {
      status: 'pending',
      promise: fetcher()
    });

    const item = resourceMap.get(key)!;
    item.promise!
      .then(data => {
        item.status = 'success';
        item.data = data;
        delete item.promise;
      })
      .catch(err => {
        item.status = 'error';
        item.error = err;
        delete item.promise;
      });
  }

  const item = resourceMap.get(key)!;

  return () => {
    if (item.status === 'pending') {
      throw item.promise;
    } else if (item.status === 'error') {
      throw item.error;
    }
    return item.data as T;
  };
}

export function invalidateResource(key: string) {
  resourceMap.delete(key);
}

export function clearAllResources() {
  resourceMap.clear();
}

実務では、このヘルパーをカスタムフックと組み合わせて使用します:

// useUser.ts
import { createResource } from './suspenseHelper';

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

export function useUser(userId: string) {
  const resource = createResource<User>(
    `user-${userId}`,
    () => fetch(`/api/users/${userId}`).then(r => r.json())
  );

  return resource();
}

// 実際のコンポーネント
export function UserCard({ userId }: { userId: string }) {
  const user = useUser(userId);

  return (
    <div className=\"user-card\">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

よくある応用パターン

パターン1: エラーバウンダリーとの組み合わせ

Suspenseだけではエラーハンドリングができないため、Error Boundaryと組み合わせる必要があります:

// 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) {
    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) {
      return (
        this.props.fallback?.(this.state.error!, this.retry) || (
          <div>
            <p>エラーが発生しました: {this.state.error?.message}</p>
            <button onClick={this.retry}>再度読み込む</button>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

// 使用例
export function UserProfileWithErrorHandling({ userId }: { userId: string }) {
  return (
    <ErrorBoundary>
      <Suspense fallback={<LoadingSpinner />}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

パターン2: リストのレンダリングと遅延読み込み

無限スクロールやページネーションで、Suspenseを使う場合の実装です:

import { Suspense, useState } from 'react';
import { createResource } from './suspenseHelper';

interface Product {
  id: string;
  name: string;
  price: number;
}

interface ProductsResponse {
  products: Product[];
  total: number;
  page: number;
}

function useProducts(page: number) {
  const resource = createResource<ProductsResponse>(
    `products-page-${page}`,
    () => fetch(`/api/products?page=${page}`).then(r => r.json())
  );
  return resource();
}

function ProductList({ page }: { page: number }) {
  const data = useProducts(page);

  return (
    <div className=\"product-list\">
      {data.products.map(product => (
        <div key={product.id} className=\"product-item\">
          <h3>{product.name}</h3>
          <p>¥{product.price.toLocaleString()}</p>
        </div>
      ))}
    </div>
  );
}

export function Products() {
  const [page, setPage] = useState(1);

  return (
    <div>
      <Suspense key={page} fallback={<div>商品を読み込み中...</div>}>
        <ProductList page={page} />
      </Suspense>
      <div className=\"pagination\">
        <button 
          onClick={() => setPage(p => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          前へ
        </button>
        <span>ページ {page}</span>
        <button onClick={() => setPage(p => p + 1)}>
          次へ
        </button>
      </div>
    </div>
  );
}

パターン3: キャッシュの無効化とリトライ

ユーザーが「更新」ボタンをクリックした場合に、キャッシュを無効化して再度データを取得する実装:

import { Suspense, useCallback, useState } from 'react';
import { createResource, invalidateResource } from './suspenseHelper';

interface Comment {
  id: string;
  author: string;
  text: string;
  createdAt: string;
}

function useComments(postId: string, version: number) {
  const cacheKey = `comments-${postId}-v${version}`;
  const resource = createResource<Comment[]>(
    cacheKey,
    () => fetch(`/api/posts/${postId}/comments`).then(r => r.json())
  );
  return resource();
}

export function PostComments({ postId }: { postId: string }) {
  const [refreshVersion, setRefreshVersion] = useState(0);

  const handleRefresh = useCallback(() => {
    // 古いキャッシュを無効化
    invalidateResource(`comments-${postId}-v${refreshVersion}`);
    // バージョンを更新してリ・レンダリング
    setRefreshVersion(v => v + 1);
  }, [postId, refreshVersion]);

  return (
    <div className=\"comments-section\">
      <div className=\"comments-header\">
        <h3>コメント</h3>
        <button onClick={handleRefresh} className=\"refresh-btn\">
          更新
        </button>
      </div>
      <Suspense key={refreshVersion} fallback={<div>コメント読み込み中...</div>}>
        <CommentList postId={postId} version={refreshVersion} />
      </Suspense>
    </div>
  );
}

function CommentList({ postId, version }: { postId: string; version: number }) {
  const comments = useComments(postId, version);

  return (
    <ul className=\"comments-list\">
      {comments.map(comment => (
        <li key={comment.id} className=\"comment-item\">
          <strong>{comment.author}</strong>
          <p>{comment.text}</p>
          <small>{new Date(comment.createdAt).toLocaleString('ja-JP')}</small>
        </li>
      ))}
    </ul>
  );
}

注意点と落とし穴

1. サーバーサイドレンダリング(SSR)との相性

Suspenseは現在のところ、SSRではまだ完全にサポートされていません。Next.jsの最新版ではサポートが進んでいますが、プロジェクトの要件を確認しておくべきです。

2. 古いブラウザのサポート

Promise.then()を使用しているため、IE11などの古いブラウザではポリフィルが必要になる可能性があります。実務では必ずターゲットブラウザを確認しましょう。

3. デバッグの難しさ

通常のコンポーネント内でPromiseをthrowするため、Error Boundaryなしでは予期しないエラーが発生する可能性があります。常にエラーハンドリングを考慮してください:

// ❌ これはデバッグが難しい
export function BadExample() {
  const data = fetchData(); // ここでPromiseがthrowされる可能性
  return <div>{data}</div>;
}

// ✅ 常にError BoundaryとSuspenseで囲む
export function GoodExample() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<Loading />}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

4. キャッシュ戦略の決定

キャッシュを保持し続けるとメモリ使用量が増加します。実務では適切なキャッシュ無効化戦略が重要です:

// TTL(Time To Live)付きのキャッシュ実装例
interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

const cache = new Map<string, CacheEntry<any>>();
const TTL = 5 * 60 * 1000; // 5分

function getCachedResource<T>(
  key: string,
  fetcher: () => Promise<T>
): T {
  const now = Date.now();
  const cached = cache.get(key);

  // キャッシュが存在し、かつTTL以内であれば使用
  if (cached && now - cached.timestamp < TTL) {
    return cached.data;
  }

  // キャッシュが古い、または存在しない場合は新規取得
  const promise = fetcher();

  // Suspense対応(Promiseをthrow)
  let status = 'pending';
  let result: T;
  let error: Error;

  promise
    .then(data => {
      result = data;
      cache.set(key, { data, timestamp: now });
      status = 'success';
    })
    .catch(e => {
      error = e;
      status = 'error';
    });

  return (() => {
    if (status === 'pending') throw promise;
    if (status === 'error') throw error;
    return result;
  })();
}

5. 複数のSuspenseの管理

深くネストされたSuspenseは管理が複雑になります。適切な粒度で分割することが重要です:

// ❌ ネストが深すぎて管理しづらい
<Suspense fallback={<LoadingA />}>
  <A />
  <Suspense fallback={<LoadingB />}>
    <B />
    <Suspense fallback={<LoadingC />}>
      <C />
    </Suspense>
  </Suspense>
</Suspense>

// ✅ コンポーネントを分割して管理
<Suspense fallback={<LoadingA />}>
  <A />
</Suspense>
<Suspense fallback={<LoadingB />}>
  <B />
</Suspense>
<Suspense fallback={<LoadingC />}>
  <C />
</Suspense>

実務での推奨される構成

実務でSuspenseを導入する際は、以下のような構成を推奨します:

// hooks/useApi.ts
import { createResource, invalidateResource } from '../utils/suspenseHelper';

export function useApi<T>(
  endpoint: string,
  options?: { skip?: boolean; ttl?: number }
) {
  const skip = options?.skip ?? false;

  if (skip) {
    return null;
  }

  const resource = createResource<T>(endpoint, async () => {
    const response = await fetch(endpoint);
    if (!response.ok) {
      throw new Error(`API error: ${response.status}`);
    }
    return response.json();
  });

  return {
    data: resource(),
    refresh: () => invalidateResource(endpoint),
  };
}

// components/UserProfileContainer.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { useApi } from '../hooks/useApi';

function UserProfileContent({ userId }: { userId: string }) {
  const userApi = useApi(`/api/users/${userId}`);
  const user = userApi?.data;

  return (
    <div className=\"user-profile\">
      <header>
        <h1>{user.name}</h1>
        <button onClick={() => userApi?.refresh()}>更新</button>
      </header>
      <section>
        <p>メール: {user.email}</p>
        <p>登録日: {new Date(user.createdAt).toLocaleDateString('ja-JP')}</p>
      </section>
    </div>
  );
}

export function UserProfileContainer({ userId }: { userId: string }) {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div className=\"skeleton\">読み込み中...</div>}>
        <UserProfileContent userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

まとめ

React Suspenseは、一見すると複雑に見える非同期データ取得の管理を劇的にシンプルにする機能です。従来のローディング状態管理に比べて、コンポーネント内での条件分岐が減り、宣言的で読みやすいコードになります。

実務での導入には、Error Boundaryとの組み合わせ、適切なキャッシュ戦略、そしてネストの深さへの注意が重要です。最初は小規模な機能から始めて、チーム内でベストプラクティスを構築することをお勧めします。

Suspenseはまだ進化中の機能ですが、React 18以降では段階的にサポートが拡張されています。プロジェクトのロードマップに組み込む価値は十分にあります。

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