日本語

React Suspenseによるデータ取得をマスターしましょう。ローディング状態を宣言的に管理し、トランジションでUXを向上させ、Error Boundaryでエラーを処理する方法を学びます。

React Suspense Boundary:宣言的なローディング状態管理の深掘り

現代のWeb開発の世界では、シームレスで応答性の高いユーザーエクスペリエンスを創出することが最も重要です。開発者が直面する最も根強い課題の一つが、ローディング状態の管理です。ユーザープロファイルのデータ取得から、アプリケーションの新しいセクションの読み込みまで、待機時間は非常に重要です。歴史的に、これはisLoadingisFetchinghasErrorといったブール値のフラグがコンポーネント全体に散らばり、複雑に絡み合ったものでした。この命令的なアプローチは、コードを乱雑にし、ロジックを複雑化させ、競合状態のようなバグの頻繁な原因となります。

そこで登場するのがReact Suspenseです。当初はReact.lazy()によるコード分割のために導入されましたが、React 18でその機能は劇的に拡張され、非同期操作、特にデータ取得を扱うための強力で第一級のメカニズムとなりました。Suspenseは、ローディング状態を宣言的な方法で管理することを可能にし、コンポーネントの記述方法や考え方を根本的に変えます。「読み込み中ですか?」と尋ねる代わりに、コンポーネントは単に「このデータをレンダリングする必要があります。待っている間、このフォールバックUIを表示してください」と伝えることができます。

この包括的なガイドでは、従来のローディング状態管理の手法から、React Suspenseの宣言的なパラダイムへの旅にご案内します。Suspense Boundaryとは何か、コード分割とデータ取得の両方でどのように機能するのか、そしてユーザーをイライラさせるのではなく、喜ばせるような複雑なローディングUIをどのように構築するかを探ります。

従来の方法:手動でのローディング状態管理の煩雑さ

Suspenseの優雅さを十分に理解する前に、それが解決する問題を理解することが不可欠です。useEffectuseStateフックを使用してデータを取得する典型的なコンポーネントを見てみましょう。

ユーザーデータを取得して表示する必要があるコンポーネントを想像してみてください:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset state for new userId
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Re-fetch when userId changes

  if (isLoading) {
    return <p>Loading profile...</p>;
  }

  if (error) {
    return <p>Error: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

このパターンは機能的ですが、いくつかの欠点があります:

React Suspenseの登場:パラダイムシフト

Suspenseはこのモデルを根本から覆します。コンポーネントが内部でローディング状態を管理するのではなく、非同期操作への依存をReactに直接伝えます。必要なデータがまだ利用できない場合、コンポーネントはレンダリングを「サスペンド(中断)」します。

コンポーネントがサスペンドすると、Reactはコンポーネントツリーを遡って最も近いSuspense Boundaryを探します。Suspense Boundaryは、<Suspense>を使用してツリー内に定義するコンポーネントです。このBoundaryは、その中のすべてのコンポーネントがデータ依存関係を解決するまで、フォールバックUI(スピナーやスケルトンローダーなど)をレンダリングします。

中心的な考え方は、データ依存性をそれを必要とするコンポーネントと共存させつつ、ローディングUIをコンポーネントツリーの上位レベルで一元管理することです。これにより、コンポーネントのロジックがクリーンになり、ユーザーのローディング体験を強力に制御できるようになります。

コンポーネントはどのように「サスペンド」するのか?

Suspenseの背後にある魔法は、最初は奇妙に思えるかもしれないパターンにあります:Promiseをスローすることです。Suspense対応のデータソースは次のように動作します:

  1. コンポーネントがデータを要求すると、データソースはデータがキャッシュされているか確認します。
  2. データが利用可能な場合、同期的にそれを返します。
  3. データが利用できない場合(つまり、現在取得中である場合)、データソースは進行中のフェッチリクエストを表すPromiseをスローします

ReactはこのスローされたPromiseをキャッチします。アプリがクラッシュすることはありません。代わりに、「このコンポーネントはまだレンダリングの準備ができていません。中断して、フォールバックを表示するために上のSuspense Boundaryを探してください」というシグナルとして解釈します。Promiseが解決されると、Reactはコンポーネントのレンダリングを再試行し、コンポーネントはデータを受け取って正常にレンダリングされます。

<Suspense> Boundary:ローディングUIの宣言子

<Suspense>コンポーネントはこのパターンの心臓部です。使用方法は非常にシンプルで、必須のプロップであるfallbackを1つ取ります。


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>My Application</h1>
      <Suspense fallback={<p>Loading content...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

この例では、SomeComponentThatFetchesDataがサスペンドすると、データが準備できるまでユーザーには「Loading content...」というメッセージが表示されます。フォールバックは、単純な文字列から複雑なスケルトンコンポーネントまで、任意の有効なReactノードにすることができます。

古典的なユースケース:React.lazy()によるコード分割

Suspenseの最も確立された用途はコード分割です。これにより、コンポーネントのJavaScriptの読み込みを、実際に必要になるまで遅延させることができます。


import React, { Suspense, lazy } from 'react';

// このコンポーネントのコードは初期バンドルには含まれません。
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>Some content that loads immediately</h2>
      <Suspense fallback={<div>Loading component...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

ここで、ReactはHeavyComponentを初めてレンダリングしようとするときにのみ、そのJavaScriptをフェッチします。フェッチおよび解析中は、Suspenseのフォールバックが表示されます。これは、初期ページ読み込み時間を改善するための強力なテクニックです。

現代の最前線:Suspenseによるデータ取得

ReactはSuspenseのメカニズムを提供しますが、特定のデータ取得クライアントは提供しません。データ取得にSuspenseを使用するには、それと統合されたデータソース(つまり、データが保留中のときにPromiseをスローするもの)が必要です。

RelayやNext.jsのようなフレームワークには、Suspenseの第一級のサポートが組み込まれています。TanStack Query(旧React Query)やSWRのような人気のデータ取得ライブラリも、実験的または完全なサポートを提供しています。

概念を理解するために、fetch APIをSuspense互換にするための非常にシンプルな概念的なラッパーを作成してみましょう。注意:これは教育目的の簡略化された例であり、本番環境での使用には適していません。適切なキャッシングやエラーハンドリングの複雑さが欠けています。


// data-fetcher.js
// 結果を保存するためのシンプルなキャッシュ
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // これが魔法です!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Fetch failed with status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

このラッパーは、各URLに対してシンプルな状態を維持します。fetchDataが呼び出されると、状態を確認します。保留中であれば、promiseをスローします。成功していれば、データを返します。では、このラッパーを使ってUserProfileコンポーネントを書き直してみましょう。


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// 実際にデータを使用するコンポーネント
function ProfileDetails({ userId }) {
  // データを読み込もうとします。準備ができていなければ、これはサスペンドします。
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// ローディング状態のUIを定義する親コンポーネント
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

この違いを見てください! ProfileDetailsコンポーネントはクリーンで、データのレンダリングだけに集中しています。isLoadingerrorの状態はありません。必要なデータを単純に要求します。ローディングインジケーターを表示する責任は親コンポーネントであるUserProfileに移され、待機中に何を表示するかを宣言的に記述しています。

複雑なローディング状態のオーケストレーション

Suspenseの真価は、複数の非同期依存関係を持つ複雑なUIを構築する際に明らかになります。

段階的なUIのためのネストされたSuspense Boundary

Suspense Boundaryをネストさせることで、より洗練されたローディング体験を作り出すことができます。サイドバー、メインコンテンツエリア、最近のアクティビティリストを持つダッシュボードページを想像してみてください。これらのそれぞれが独自のデータ取得を必要とするかもしれません。


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Loading navigation...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

この構造では:

これにより、有用なコンテンツをできるだけ早くユーザーに表示でき、知覚的なパフォーマンスを劇的に向上させることができます。

UIの「ポップコーン化」を避ける

時々、段階的なアプローチは、複数のスピナーが素早く連続して表示されたり消えたりする、不快な効果(しばしば「ポップコーン化」と呼ばれる)を引き起こすことがあります。これを解決するために、Suspense Boundaryをツリーの上位に移動させることができます。


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

このバージョンでは、すべての子コンポーネント(SidebarMainContentActivityFeed)のデータが準備できるまで、単一のDashboardSkeletonが表示されます。その後、ダッシュボード全体が一度に表示されます。ネストされたBoundaryと単一の上位Boundaryのどちらを選択するかはUXデザインの決定事項であり、Suspenseによってその実装が非常に簡単になります。

Error Boundaryによるエラーハンドリング

SuspenseはPromiseのpending(保留)状態を処理しますが、rejected(拒否)状態についてはどうでしょうか?コンポーネントによってスローされたPromiseが拒否された場合(例:ネットワークエラー)、それはReactの他のレンダリングエラーと同様に扱われます。

解決策はError Boundaryを使用することです。Error Boundaryは、特別なライフサイクルメソッドであるcomponentDidCatch()または静的メソッドgetDerivedStateFromError()を定義するクラスコンポーネントです。それは子コンポーネントツリー内のどこでもJavaScriptエラーをキャッチし、それらのエラーをログに記録し、フォールバックUIを表示します。

以下は、シンプルなError Boundaryコンポーネントです:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // 次のレンダリングでフォールバックUIが表示されるようにstateを更新します。
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // エラーレポートサービスにエラーを記録することもできます
    console.error("Caught an error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 任意のカスタムフォールバックUIをレンダリングできます
      return <h1>Something went wrong. Please try again.</h1>;
    }

    return this.props.children; 
  }
}

そして、Error BoundaryとSuspenseを組み合わせることで、保留、成功、エラーの3つの状態すべてを処理する堅牢なシステムを構築できます。


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>User Information</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

このパターンでは、UserProfile内のデータ取得が成功すればプロファイルが表示されます。保留中であればSuspenseのフォールバックが表示されます。失敗すればError Boundaryのフォールバックが表示されます。ロジックは宣言的で構成可能であり、理解しやすいです。

Transition:ノンブロッキングUIアップデートの鍵

パズルには最後のピースが一つあります。ユーザーのインタラクションが新しいデータ取得をトリガーする場合、例えば「次へ」ボタンをクリックして別のユーザープロファイルを表示するような場合を考えてみましょう。上記の設定では、ボタンがクリックされてuserIdプロップが変更された瞬間に、UserProfileコンポーネントは再びサスペンドします。これは、現在表示されているプロファイルが消えてローディングフォールバックに置き換えられることを意味し、唐突で途切れ途切れな感じがすることがあります。

ここでトランジションが登場します。トランジションはReact 18の新機能で、特定の状態更新を緊急でないものとしてマークすることができます。状態更新がトランジションでラップされると、Reactはバックグラウンドで新しいコンテンツを準備している間、古いUI(古いコンテンツ)を表示し続けます。新しいコンテンツが表示可能になった時点で初めてUIの更新をコミットします。

このための主要なAPIはuseTransitionフックです。


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        Next User
      </button>

      {isPending && <span> Loading new profile...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Loading initial profile...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

これで、何が起こるかというと:

  1. userId: 1の初期プロファイルがロードされ、Suspenseのフォールバックが表示されます。
  2. ユーザーが「Next User」をクリックします。
  3. setUserIdの呼び出しがstartTransitionでラップされます。
  4. Reactは新しいuserIdである2でUserProfileをメモリ内でレンダリングし始めます。これにより、サスペンドが発生します。
  5. 重要な点として、ReactはSuspenseのフォールバックを表示する代わりに、古いUI(ユーザー1のプロファイル)を画面に表示し続けます。
  6. useTransitionによって返されるisPendingブール値がtrueになり、古いコンテンツをアンマウントすることなく、控えめなインラインのローディングインジケーターを表示できます。
  7. ユーザー2のデータが取得され、UserProfileが正常にレンダリングできるようになったら、Reactは更新をコミットし、新しいプロファイルがシームレスに表示されます。

トランジションは制御の最終レイヤーを提供し、決して唐突に感じることのない、洗練されたユーザーフレンドリーなローディング体験を構築することを可能にします。

ベストプラクティスとグローバルな考慮事項

結論

React Suspenseは単なる新機能以上のものであり、Reactアプリケーションにおける非同期性の扱い方における根本的な進化を表しています。手動の命令的なローディングフラグから離れ、宣言的なモデルを採用することで、よりクリーンで、より回復力があり、より構成しやすいコンポーネントを書くことができます。

保留状態には<Suspense>、失敗状態にはError Boundary、そしてシームレスな更新にはuseTransitionを組み合わせることで、完全で強力なツールキットを自由に使うことができます。単純なローディングスピナーから複雑で段階的なダッシュボードの表示まで、最小限で予測可能なコードですべてを構築できます。プロジェクトにSuspenseを統合し始めると、アプリケーションのパフォーマンスとユーザーエクスペリエンスが向上するだけでなく、状態管理のロジックが劇的に簡素化され、本当に重要なこと、つまり素晴らしい機能の構築に集中できるようになることがわかるでしょう。