コード分割にとどまらないReact Suspenseのデータフェッチングを探求。Fetch-As-You-Render、エラーハンドリング、そしてグローバルアプリケーション向けの将来性のあるパターンを解説します。
React Suspenseリソースローディング:最新データフェッチングパターンの完全習得
変化の激しいウェブ開発の世界では、ユーザーエクスペリエンス(UX)が最も重要です。アプリケーションは、ネットワーク状況やデバイスの性能に関わらず、高速で応答性が高く、快適であることが期待されます。React開発者にとって、これはしばしば複雑な状態管理、入り組んだローディングインジケーター、そしてデータフェッチングのウォーターフォールとの絶え間ない戦いを意味します。そこで登場するのがReact Suspenseです。これは、非同期操作、特にデータフェッチングの扱い方を根本的に変えるために設計された、強力でありながら誤解されがちな機能です。
当初はReact.lazy()
によるコード分割のために導入されましたが、Suspenseの真のポテンシャルは、APIからのデータを含む*あらゆる*非同期リソースの読み込みを調整する能力にあります。この包括的なガイドでは、リソースローディングのためのReact Suspenseを深く掘り下げ、そのコアコンセプト、基本的なデータフェッチングパターン、そしてパフォーマンスと回復力に優れたグローバルアプリケーションを構築するための実践的な考慮事項を探求します。
Reactにおけるデータフェッチングの進化:命令的から宣言的へ
長年にわたり、Reactコンポーネントでのデータフェッチングは主に共通のパターンに依存していました。それは、useEffect
フックを使ってAPIコールを開始し、useState
でローディングとエラーの状態を管理し、これらの状態に基づいて条件付きでレンダリングするというものです。このアプローチは機能的ではあるものの、しばしばいくつかの課題につながりました。
- ローディング状態の増殖:データを必要とするほぼすべてのコンポーネントが、独自の
isLoading
、isError
、data
状態を必要とし、反復的なボイラープレートコードを生み出しました。 - ウォーターフォールと競合状態:ネストされたコンポーネントがデータをフェッチすると、親コンポーネントがデータをフェッチし、レンダリングし、次に子コンポーネントがデータをフェッチする、といった逐次的なリクエスト(ウォーターフォール)が発生することがよくありました。これにより、全体の読み込み時間が増加しました。また、複数のリクエストが開始され、レスポンスが順不同で到着した場合に競合状態が発生する可能性もありました。
- 複雑なエラーハンドリング:多数のコンポーネントにエラーメッセージと回復ロジックを分散させることは、プロップスのバケツリレーやグローバルな状態管理ソリューションを必要とし、面倒になる可能性がありました。
- 不快なユーザーエクスペリエンス:複数のスピナーが現れたり消えたりすることや、突然のコンテンツのずれ(レイアウトシフト)は、ユーザーにとって不快な体験を生み出す可能性がありました。
- データと状態のためのプロップスのバケツリレー:フェッチしたデータと関連するローディング/エラー状態をコンポーネントの複数の階層を通じて渡すことは、複雑さの一般的な原因となっていました。
Suspenseを使用しない典型的なデータフェッチングのシナリオを考えてみましょう:
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(() => {
const fetchUser = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [userId]);
if (isLoading) {
return <p>ユーザープロファイルを読み込み中...</p>;
}
if (error) {
return <p style={"color: red;"}>エラー: {error.message}</p>;
}
if (!user) {
return <p>利用可能なユーザーデータがありません。</p>;
}
return (
<div>
<h2>ユーザー: {user.name}</h2>
<p>メールアドレス: {user.email}</p>
<!-- その他のユーザー詳細 -->
</div>
);
}
function App() {
return (
<div>
<h1>アプリケーションへようこそ</h1>
<UserProfile userId={"123"} />
</div>
);
}
このパターンは広く使われていますが、コンポーネントに自身の非同期状態を管理させることを強制し、UIとデータフェッチングロジックの間に密結合な関係を生み出すことがよくあります。Suspenseは、より宣言的で合理化された代替手段を提供します。
コード分割を超えたReact Suspenseの理解
ほとんどの開発者は、最初にコード分割のためのReact.lazy()
を通じてSuspenseに出会います。これにより、コンポーネントのコードが必要になるまで読み込みを遅延させることができます。例:
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./MyHeavyComponent'));
function App() {
return (
<Suspense fallback={<div>コンポーネントを読み込み中...</div>}>
<LazyComponent />
</Suspense>
);
}
このシナリオでは、MyHeavyComponent
がまだ読み込まれていない場合、<Suspense>
境界はlazy()
によってスローされたPromiseをキャッチし、コンポーネントのコードが準備できるまでfallback
を表示します。ここでの重要な洞察は、Suspenseはレンダリング中にスローされたPromiseをキャッチすることで機能するということです。
このメカニズムはコードの読み込みに限定されません。レンダリング中に呼び出され、Promiseをスローする(例えば、リソースがまだ利用できないため)どんな関数も、コンポーネントツリーの上位にあるSuspense境界によってキャッチできます。Promiseが解決されると、Reactはコンポーネントの再レンダリングを試み、リソースが利用可能になっていれば、フォールバックは非表示になり、実際のコンテンツが表示されます。
データフェッチングにおけるSuspenseのコアコンセプト
データフェッチングにSuspenseを活用するためには、いくつかのコア原則を理解する必要があります。
1. Promiseをスローする
Promiseを解決するためにasync/await
を使用する従来の非同期コードとは異なり、Suspenseはデータが準備できていない場合にPromiseを*スロー*する関数に依存します。Reactがそのような関数を呼び出すコンポーネントをレンダリングしようとし、データがまだ保留中の場合、Promiseがスローされます。Reactはその後、そのコンポーネントとその子要素のレンダリングを「一時停止」し、最も近い<Suspense>
境界を探します。
2. Suspense境界
<Suspense>
コンポーネントは、Promiseに対するエラー境界として機能します。これはfallback
プロップを取り、その子要素(またはその子孫)のいずれかがサスペンド状態(つまり、Promiseをスローしている)の間、レンダリングするUIとなります。そのサブツリー内でスローされたすべてのPromiseが解決されると、フォールバックは実際のコンテンツに置き換えられます。
単一のSuspense境界で複数の非同期操作を管理できます。たとえば、同じ<Suspense>
境界内に2つのコンポーネントがあり、それぞれがデータをフェッチする必要がある場合、フォールバックは*両方*のデータフェッチが完了するまで表示されます。これにより、部分的なUIが表示されるのを避け、より協調のとれたローディング体験を提供します。
3. キャッシュ/リソースマネージャー(ユーザーランドの責任)
重要なことに、Suspense自体はデータフェッチングやキャッシングを処理しません。それは単なる調整メカニズムです。データフェッチングでSuspenseを機能させるためには、以下のことを行うレイヤーが必要です。
- データフェッチを開始する。
- 結果(解決されたデータまたは保留中のPromise)をキャッシュする。
- キャッシュされたデータを即座に返す(利用可能な場合)か、保留中のPromiseをスローする(利用できない場合)同期的な
read()
メソッドを提供する。
この「リソースマネージャー」は、通常、各リソースの状態(保留中、解決済み、またはエラー)を格納するために、単純なキャッシュ(例:Mapやオブジェクト)を使用して実装されます。デモンストレーション目的でこれを手動で構築することもできますが、実際のアプリケーションでは、Suspenseと統合された堅牢なデータフェッチングライブラリを使用することになります。
4. コンカレントモード(React 18の機能強化)
Suspenseは古いバージョンのReactでも使用できますが、その真価はConcurrent React(React 18ではcreateRoot
でデフォルトで有効)で発揮されます。コンカレントモードにより、Reactはレンダリング作業を中断、一時停止、再開できます。これは次のことを意味します。
- ノンブロッキングなUI更新:Suspenseがフォールバックを表示している間、ReactはサスペンドされていないUIの他の部分のレンダリングを続行したり、メインスレッドをブロックすることなくバックグラウンドで新しいUIを準備したりできます。
- トランジション:
useTransition
のような新しいAPIを使用すると、特定の更新を「トランジション」としてマークでき、Reactはこれを中断して緊急度を下げることができ、データフェッチング中のよりスムーズなUI変更を提供します。
Suspenseを使ったデータフェッチングパターン
Suspenseの登場によるデータフェッチングパターンの進化を探ってみましょう。
パターン1:Fetch-Then-Render(Suspenseでラップした従来の方法)
これは、データをフェッチし、その後にのみコンポーネントをレンダリングするという古典的なアプローチです。データに対して直接「Promiseをスローする」メカニズムを活用するわけではありませんが、*最終的に*データをレンダリングするコンポーネントをSuspense境界でラップしてフォールバックを提供することができます。これは、内部のデータフェッチングがまだ従来のuseEffect
ベースであっても、最終的に準備が整うコンポーネントに対してSuspenseを汎用的なローディングUIオーケストレーターとして使用することに関するものです。
import React, { Suspense, useState, useEffect } from 'react';
function UserDetails({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setIsLoading(false);
};
fetchUserData();
}, [userId]);
if (isLoading) {
return <p>ユーザー詳細を読み込み中...</p>;
}
return (
<div>
<h3>ユーザー: {user.name}</h3>
<p>メールアドレス: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Fetch-Then-Renderの例</h1>
<Suspense fallback={<div>ページ全体を読み込み中...</div>}>
<UserDetails userId={"1"} />
</Suspense>
</div>
);
}
長所:理解しやすく、後方互換性がある。グローバルなローディング状態を素早く追加する方法として使用できる。
短所:UserDetails
内のボイラープレートを排除しない。コンポーネントが逐次的にデータをフェッチする場合、依然としてウォーターフォールが発生しやすい。データ自体に対してSuspenseの「スローしてキャッチする」メカニズムを真に活用していない。
パターン2:Render-Then-Fetch(レンダリング内でのフェッチ、本番環境向けではない)
このパターンは、主にSuspenseで直接やってはいけないことを説明するためのものであり、綿密に扱わないと無限ループやパフォーマンス問題につながる可能性があります。これは、適切なキャッシングメカニズムなしに、コンポーネントのレンダーフェーズ内で直接データをフェッチしたり、サスペンドする関数を呼び出したりしようとすることを含みます。
// 適切なキャッシングレイヤーなしで本番環境では使用しないでください
// これは、直接的な「スロー」が概念的にどのように機能するかを説明するためだけのものです。
let fetchedData = null;
let dataPromise = null;
function fetchDataSynchronously(url) {
if (fetchedData) {
return fetchedData;
}
if (!dataPromise) {
dataPromise = fetch(url)
.then(res => res.json())
.then(data => { fetchedData = data; dataPromise = null; return data; })
.catch(err => { dataPromise = null; throw err; });
}
throw dataPromise; // ここでSuspenseが機能します
}
function UserDetailsBadExample({ userId }) {
const user = fetchDataSynchronously(`/api/users/${userId}`);
return (
<div>
<h3>ユーザー: {user.name}</h3>
<p>メールアドレス: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Render-Then-Fetch(説明用、直接は非推奨)</h1>
<Suspense fallback={<div>ユーザーを読み込み中...</div>}>
<UserDetailsBadExample userId={"2"} />
</Suspense>
</div>
);
}
長所:コンポーネントが直接データに「要求」し、準備ができていなければサスペンドする方法を示すことができる。
短所:本番環境では非常に問題が多い。この手動のグローバルなfetchedData
とdataPromise
システムは単純であり、複数のリクエスト、無効化、またはエラー状態を堅牢に処理しない。これは「Promiseをスローする」概念の原始的な図解であり、採用すべきパターンではない。
パターン3:Fetch-As-You-Render(理想的なSuspenseパターン)
これこそが、Suspenseがデータフェッチングに真に可能にするパラダイムシフトです。コンポーネントがレンダリングされるのを待ってからデータをフェッチしたり、すべてのデータを事前にフェッチしたりする代わりに、Fetch-As-You-Renderは、可能な限り早く、多くの場合、レンダリングプロセスの*前*または*同時に*データフェッチを開始することを意味します。コンポーネントはその後、キャッシュからデータを「読み取り」、データが準備できていなければサスペンドします。中心的なアイデアは、データフェッチングロジックをコンポーネントのレンダリングロジックから分離することです。
Fetch-As-You-Renderを実装するには、以下のメカニズムが必要です。
- コンポーネントのレンダー関数の外でデータフェッチを開始する(例:ルートに入ったとき、またはボタンがクリックされたとき)。
- Promiseまたは解決されたデータをキャッシュに保存する。
- コンポーネントがこのキャッシュから「読み取る」方法を提供する。データがまだ利用できない場合、読み取り関数は保留中のPromiseをスローする。
このパターンはウォーターフォールの問題を解決します。2つの異なるコンポーネントがデータを必要とする場合、それらのリクエストは並行して開始でき、UIは単一のSuspense境界によって調整され、*両方*が準備できたときにのみ表示されます。
手動実装(理解のため)
根底にあるメカニズムを理解するために、簡略化された手動のリソースマネージャーを作成してみましょう。実際のアプリケーションでは、専用のライブラリを使用します。
import React, { Suspense } from 'react';
// --- シンプルなキャッシュ/リソースマネージャー --- //
const cache = new Map();
function createResource(promise) {
let status = 'pending';
let result;
let suspender = promise.then(
(r) => {
status = 'success';
result = r;
},
(e) => {
status = 'error';
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
function fetchData(key, fetcher) {
if (!cache.has(key)) {
cache.set(key, createResource(fetcher()));
}
return cache.get(key);
}
// --- データフェッチング関数 --- //
const fetchUserById = (id) => {
console.log(`ユーザー ${id} をフェッチ中...`);
return new Promise(resolve => setTimeout(() => {
const users = {
'1': { id: '1', name: 'Alice Smith', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
'3': { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }
};
resolve(users[id]);
}, 1500));
};
const fetchPostsByUserId = (userId) => {
console.log(`ユーザー ${userId} の投稿をフェッチ中...`);
return new Promise(resolve => setTimeout(() => {
const posts = {
'1': [{ id: 'p1', title: '私の最初の投稿' }, { id: 'p2', title: '旅行の冒険' }],
'2': [{ id: 'p3', title: 'コーディングの洞察' }],
'3': [{ id: 'p4', title: '世界のトレンド' }, { id: 'p5', title: '地元の料理' }]
};
resolve(posts[userId] || []);
}, 2000));
};
// --- コンポーネント --- //
function UserProfile({ userId }) {
const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
const user = userResource.read(); // ユーザーデータが準備できていなければサスペンドします
return (
<div>
<h3>ユーザー: {user.name}</h3>
<p>メールアドレス: {user.email}</p>
</div>
);
}
function UserPosts({ userId }) {
const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
const posts = postsResource.read(); // 投稿データが準備できていなければサスペンドします
return (
<div>
<h4>{userId}による投稿:</h4>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
{posts.length === 0 && <li>投稿は見つかりませんでした。</li>}
</ul>
</div>
);
}
// --- アプリケーション --- //
let initialUserResource = null;
let initialPostsResource = null;
function prefetchDataForUser(userId) {
initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}
// Appコンポーネントがレンダリングされる前にデータを事前フェッチ
prefetchDataForUser('1');
function App() {
return (
<div>
<h1>Suspenseを使ったFetch-As-You-Render</h1>
<p>これは、データフェッチングが並行して行われ、Suspenseによって調整される様子を示しています。</p>
<Suspense fallback={<div>ユーザープロファイルと投稿を読み込み中...</div>}>
<UserProfile userId={"1"} />
<UserPosts userId={"1"} />
</Suspense>
<h2>別のセクション</h2>
<Suspense fallback={<div>別のユーザーを読み込み中...</div>}>
<UserProfile userId={"2"} />
</Suspense>
</div>
);
}
この例では:
createResource
とfetchData
関数が基本的なキャッシングメカニズムを設定します。UserProfile
またはUserPosts
がresource.read()
を呼び出すと、すぐにデータを取得するか、Promiseがスローされます。- 最も近い
<Suspense>
境界がPromiseをキャッチし、フォールバックを表示します。 - 重要なことに、
App
コンポーネントがレンダリングされる*前*にprefetchDataForUser('1')
を呼び出すことができ、データフェッチングをさらに早く開始できます。
Fetch-As-You-Renderのためのライブラリ
堅牢なリソースマネージャーを手動で構築・維持するのは複雑です。幸いなことに、いくつかの成熟したデータフェッチングライブラリがSuspenseを採用または採用しつつあり、実績のあるソリューションを提供しています。
- React Query (TanStack Query): Suspenseをサポートする強力なデータフェッチングとキャッシングレイヤーを提供します。サスペンド可能な
useQuery
のようなフックを提供します。REST APIに優れています。 - SWR (Stale-While-Revalidate): Suspenseを完全にサポートする、もう一つの人気で軽量なデータフェッチングライブラリ。REST APIに理想的で、データを迅速に提供し(古いデータ)、バックグラウンドで再検証することに重点を置いています。
- Apollo Client: GraphQLクエリとミューテーションのための堅牢なSuspense統合を持つ包括的なGraphQLクライアント。
- Relay: Facebook独自のGraphQLクライアントで、SuspenseとConcurrent Reactのためにゼロから設計されています。特定のGraphQLスキーマとコンパイルステップが必要ですが、比類のないパフォーマンスとデータの一貫性を提供します。
- Urql: Suspenseをサポートする、軽量で高度にカスタマイズ可能なGraphQLクライアント。
これらのライブラリは、リソースの作成と管理、キャッシング、再検証、オプティミスティックアップデート、エラーハンドリングの複雑さを抽象化し、Fetch-As-You-Renderの実装をはるかに容易にします。
パターン4:Suspense対応ライブラリによるプリフェッチ
プリフェッチは、ユーザーが近い将来に必要とする可能性が高いデータを、明示的に要求する前に積極的にフェッチする強力な最適化です。これにより、体感パフォーマンスが劇的に向上します。
Suspense対応ライブラリを使用すると、プリフェッチがシームレスになります。リンクにカーソルを合わせたり、ボタンにマウスオーバーしたりするなど、すぐにUIを変更しないユーザーインタラクションでデータフェッチをトリガーできます。
import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// これらがAPIコールであると仮定します
const fetchProductById = async (id) => {
console.log(`製品 ${id} をフェッチ中...`);
return new Promise(resolve => setTimeout(() => {
const products = {
'A001': { id: 'A001', name: 'グローバルウィジェットX', price: 29.99, description: '国際的に使用できる多用途ウィジェット。' },
'B002': { id: 'B002', name: 'ユニバーサルガジェットY', price: 149.99, description: '世界中で愛される最先端ガジェット。' },
};
resolve(products[id]);
}, 1000));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // デフォルトですべてのクエリでSuspenseを有効にする
},
},
});
function ProductDetails({ productId }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
return (
<div style={{\"border\": \"1px solid #ccc\", \"padding\": \"15px\", \"margin\": \"10px 0\"}}>
<h3>{product.name}</h3>
<p>価格: ${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
);
}
function ProductList() {
const handleProductHover = (productId) => {
// ユーザーが製品リンクにカーソルを合わせたときにデータをプリフェッチ
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
console.log(`製品 ${productId} をプリフェッチ中`);
};
return (
<div>
<h2>利用可能な製品:</h2>
<ul>
<li>
<a href=\"#\" onMouseEnter={() => handleProductHover('A001')}
onClick={(e) => { e.preventDefault(); /* ナビゲートまたは詳細表示 */ }}
>グローバルウィジェットX (A001)</a>
</li>
<li>
<a href=\"#\" onMouseEnter={() => handleProductHover('B002')}
onClick={(e) => { e.preventDefault(); /* ナビゲートまたは詳細表示 */ }}
>ユニバーサルガジェットY (B002)</a>
</li>
</ul>
<p>製品リンクにカーソルを合わせるとプリフェッチが動作するのを確認できます。ネットワークタブを開いて観察してください。</p>
</div>
);
}
function App() {
const [showProductA, setShowProductA] = React.useState(false);
const [showProductB, setShowProductB] = React.useState(false);
return (
<QueryClientProvider client={queryClient}>
<h1>React Suspenseによるプリフェッチ (React Query)</h1>
<ProductList />
<button onClick={() => setShowProductA(true)}>グローバルウィジェットXを表示</button>
<button onClick={() => setShowProductB(true)}>ユニバーサルガジェットYを表示</button>
{showProductA && (
<Suspense fallback={<p>グローバルウィジェットXを読み込み中...</p>}>
<ProductDetails productId=\"A001\" />
</Suspense>
)}
{showProductB && (
<Suspense fallback={<p>ユニバーサルガジェットYを読み込み中...</p>}>
<ProductDetails productId=\"B002\" />
</Suspense>
)}
</QueryClientProvider>
);
}
この例では、製品リンクにカーソルを合わせると`queryClient.prefetchQuery`がトリガーされ、バックグラウンドでデータフェッチが開始されます。ユーザーがその後ボタンをクリックして製品詳細を表示し、プリフェッチによってデータが既にキャッシュにある場合、コンポーネントはサスペンドすることなく即座にレンダリングされます。プリフェッチがまだ進行中または開始されていない場合、Suspenseはデータが準備できるまでフォールバックを表示します。
Suspenseとエラー境界によるエラーハンドリング
Suspenseは「ローディング」状態をフォールバックを表示することで処理しますが、「エラー」状態を直接処理しません。サスペンドするコンポーネントによってスローされたPromiseが拒否された場合(つまり、データフェッチが失敗した場合)、このエラーはコンポーネントツリーを上に伝播します。これらのエラーを適切に処理し、適切なUIを表示するには、エラー境界(Error Boundaries)を使用する必要があります。
エラー境界は、componentDidCatch
またはstatic getDerivedStateFromError
ライフサイクルメソッドのいずれかを実装するReactコンポーネントです。子コンポーネントツリー内のどこでもJavaScriptエラーをキャッチします。これには、Suspenseが保留中であれば通常キャッチするPromiseによってスローされたエラーも含まれます。
import React, { Suspense, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// --- エラー境界コンポーネント --- //
class MyErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// 次のレンダリングでフォールバックUIが表示されるように状態を更新します。
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// エラーレポートサービスにエラーをログ記録することもできます
console.error("エラーをキャッチしました:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// 任意のカスタムフォールバックUIをレンダリングできます
return (
<div style={{\"border\": \"2px solid red\", \"padding\": \"20px\", \"margin\": \"20px 0\", \"background\": \"#ffe0e0\"}}>
<h2>問題が発生しました!</h2>
<p>{this.state.error && this.state.error.message}</p>
<p>ページを再読み込みするか、サポートにお問い合わせください。</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>再試行</button>
</div>
);
}
return this.props.children;
}
}
// --- データフェッチング(エラーの可能性がある)--- //
const fetchItemById = async (id) => {
console.log(`アイテム ${id} のフェッチを試みています...`);
return new Promise((resolve, reject) => setTimeout(() => {
if (id === 'error-item') {
reject(new Error('アイテムの読み込みに失敗しました:ネットワークに到達できないか、アイテムが見つかりません。'));
} else if (id === 'slow-item') {
resolve({ id: 'slow-item', name: 'ゆっくり配送', data: 'このアイテムは時間がかかりましたが、到着しました!', status: 'success' });
} else {
resolve({ id, name: `アイテム ${id}`, data: `アイテム ${id} のデータ` });
}
}, id === 'slow-item' ? 3000 : 800));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
retry: false, // デモのため、エラーがすぐに発生するようにリトライを無効にする
},
},
});
function DisplayItem({ itemId }) {
const { data: item } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetchItemById(itemId),
});
return (
<div>
<h3>アイテム詳細:</h3>
<p>ID: {item.id}</p>
<p>名前: {item.name}</p>
<p>データ: {item.data}</p>
</div>
);
}
function App() {
const [fetchType, setFetchType] = useState('normal-item');
return (
<QueryClientProvider client={queryClient}>
<h1>Suspenseとエラー境界</h1>
<div>
<button onClick={() => setFetchType('normal-item')}>通常アイテムをフェッチ</button>
<button onClick={() => setFetchType('slow-item')}>遅いアイテムをフェッチ</button>
<button onClick={() => setFetchType('error-item')}>エラーアイテムをフェッチ</button>
</div>
<MyErrorBoundary>
<Suspense fallback={<p>Suspense経由でアイテムを読み込み中...</p>}>
<DisplayItem itemId={fetchType} />
</Suspense>
</MyErrorBoundary>
</QueryClientProvider>
);
}
Suspense境界(またはサスペンドする可能性のあるコンポーネント)をエラー境界でラップすることで、データフェッチ中のネットワーク障害やサーバーエラーがキャッチされ、適切に処理されることを保証し、アプリケーション全体がクラッシュするのを防ぎます。これにより、堅牢でユーザーフレンドリーな体験が提供され、ユーザーは問題を理解し、再試行する可能性があります。
Suspenseによる状態管理とデータ無効化
React Suspenseは主に非同期リソースの初期読み込み状態に対処するものであり、クライアントサイドのキャッシュを本質的に管理したり、データの無効化を処理したり、ミューテーション(作成、更新、削除操作)とその後のUI更新を調整したりするものではないことを明確にすることが重要です。
ここでSuspense対応のデータフェッチングライブラリ(React Query、SWR、Apollo Client、Relay)が不可欠になります。これらはSuspenseを補完し、以下を提供します。
- 堅牢なキャッシング:フェッチしたデータの洗練されたインメモリキャッシュを維持し、利用可能な場合は即座に提供し、バックグラウンドでの再検証を処理します。
- データの無効化と再フェッチ:キャッシュされたデータを「古い」ものとしてマークし、再フェッチするメカニズムを提供します(例:ミューテーション後、ユーザーインタラクション、またはウィンドウフォーカス時)。
- オプティミスティックアップデート:ミューテーションに対して、APIコールの期待される結果に基づいてUIを即座に(楽観的に)更新し、実際のAPIコールが失敗した場合はロールバックすることができます。
- グローバルな状態同期:アプリケーションの一部でデータが変更された場合、そのデータを表示するすべてのコンポーネントが自動的に更新されることを保証します。
- ミューテーションのローディングとエラー状態:
useQuery
はサスペンドするかもしれませんが、useMutation
は通常、ミューテーションプロセス自体のためのisLoading
とisError
状態を提供します。なぜなら、ミューテーションは多くの場合インタラクティブであり、即時のフィードバックが必要だからです。
堅牢なデータフェッチングライブラリなしで、手動のSuspenseリソースマネージャーの上にこれらの機能を実装することは、実質的に独自のデータフェッチングフレームワークを構築する必要があるため、大きな作業となります。
実践的な考慮事項とベストプラクティス
データフェッチングにSuspenseを採用することは、重要なアーキテクチャ上の決定です。グローバルアプリケーションにおける実践的な考慮事項をいくつか紹介します。
1. すべてのデータにSuspenseが必要なわけではない
Suspenseは、コンポーネントの初期レンダリングに直接影響する重要なデータに理想的です。重要でないデータ、バックグラウンドでのフェッチ、または強い視覚的影響なしに遅延ロードできるデータについては、従来のuseEffect
やプリレンダリングが依然として適している場合があります。Suspenseを過度に使用すると、単一のSuspense境界がすべての子要素の解決を待つため、ローディング体験の粒度が粗くなる可能性があります。
2. Suspense境界の粒度
<Suspense>
境界を慎重に配置してください。アプリケーションの最上部にある単一の大きな境界は、ページ全体をスピナーの背後に隠してしまい、イライラさせることがあります。より小さく、粒度の細かい境界を使用すると、ページの異なる部分が独立してロードされ、よりプログレッシブで応答性の高い体験が提供されます。たとえば、ユーザープロファイルコンポーネントの周りに境界を一つ、推奨製品のリストの周りにもう一つ配置するなどです。
<div>
<h1>製品ページ</h1>
<Suspense fallback={<p>主要な製品詳細を読み込み中...</p>}>
<ProductDetails id=\"prod123\" />
</Suspense>
<hr />
<h2>関連製品</h2>
<Suspense fallback={<p>関連製品を読み込み中...</p>}>
<RelatedProducts category=\"electronics\" />
</Suspense>
</div>
このアプローチは、関連製品がまだロード中であっても、ユーザーが主要な製品詳細を見ることができることを意味します。
3. サーバーサイドレンダリング(SSR)とストリーミングHTML
React 18の新しいストリーミングSSR API(renderToPipeableStream
)はSuspenseと完全に統合されています。これにより、サーバーはページの特定の部分(データに依存するコンポーネントなど)がまだロード中であっても、準備ができたHTMLをすぐに送信できます。サーバーはプレースホルダー(Suspenseフォールバックから)をストリーミングし、データが解決されたときに実際のコンテンツをストリーミングできます。これにはクライアントサイドでの完全な再レンダリングは不要です。これにより、さまざまなネットワーク状況にあるグローバルユーザーの体感読み込みパフォーマンスが大幅に向上します。
4. 段階的な採用
Suspenseを使用するためにアプリケーション全体を書き直す必要はありません。宣言的なローディングパターンから最も恩恵を受ける新しい機能やコンポーネントから始めて、段階的に導入することができます。
5. ツールとデバッグ
Suspenseはコンポーネントのロジックを簡素化しますが、デバッグは異なる場合があります。React DevToolsは、Suspense境界とその状態に関する洞察を提供します。選択したデータフェッチングライブラリが内部状態をどのように公開するか(例:React Query Devtools)に慣れておきましょう。
6. Suspenseフォールバックのタイムアウト
非常に長い読み込み時間の場合、Suspenseフォールバックにタイムアウトを導入したり、一定の遅延後に詳細なローディングインジケーターに切り替えたりしたい場合があります。React 18のuseDeferredValue
とuseTransition
フックは、これらのより微妙なローディング状態を管理するのに役立ち、新しいデータがフェッチされている間にUIの「古い」バージョンを表示したり、緊急でない更新を延期したりできます。
Reactにおけるデータフェッチングの未来:React Server Componentsとその先へ
Reactにおけるデータフェッチングの旅は、クライアントサイドのSuspenseで終わりません。React Server Components(RSC)は、クライアントとサーバーの境界を曖昧にし、データフェッチングをさらに最適化することを約束する、重要な進化を表しています。
- React Server Components (RSC): これらのコンポーネントはサーバー上でレンダリングされ、データを直接フェッチし、必要なHTMLとクライアントサイドのJavaScriptのみをブラウザに送信します。これにより、クライアントサイドのウォーターフォールが排除され、バンドルサイズが削減され、初期読み込みパフォーマンスが向上します。RSCはSuspenseと密接に連携します。サーバーコンポーネントはデータが準備できていなければサスペンドでき、サーバーはSuspenseフォールバックをクライアントにストリーミングし、データが解決されるとそれが置き換えられます。これは、複雑なデータ要件を持つアプリケーションにとってゲームチェンジャーであり、特にさまざまな地理的地域にいるレイテンシの異なるユーザーにとって、シームレスで非常にパフォーマンスの高い体験を提供します。
- 統一されたデータフェッチング:Reactの長期的なビジョンには、データフェッチングへの統一されたアプローチが含まれており、コアフレームワークまたは密接に統合されたソリューションが、サーバーとクライアントの両方でデータを読み込むための第一級のサポートを提供し、すべてがSuspenseによって調整されます。
- ライブラリの継続的な進化:データフェッチングライブラリは進化を続け、Suspenseの基本的な能力を基盤として、キャッシング、無効化、リアルタイム更新のためのさらに洗練された機能を提供し続けるでしょう。
Reactが成熟し続けるにつれて、Suspenseは高性能でユーザーフレンドリーで保守可能なアプリケーションを構築するためのパズルのますます中心的なピースになるでしょう。それは開発者を、非同期操作を処理するためのより宣言的で回復力のある方法へと導き、個々のコンポーネントから複雑さをうまく管理されたデータレイヤーへと移行させます。
結論
当初はコード分割のための機能であったReact Suspenseは、データフェッチングのための変革的なツールへと開花しました。Fetch-As-You-Renderパターンを受け入れ、Suspense対応ライブラリを活用することで、開発者はアプリケーションのユーザーエクスペリエンスを大幅に向上させ、ローディングウォーターフォールを排除し、コンポーネントロジックを簡素化し、スムーズで協調のとれたローディング状態を提供できます。堅牢なエラーハンドリングのためのエラー境界と、React Server Componentsの将来の約束と組み合わせることで、Suspenseは、パフォーマンスと回復力に優れているだけでなく、世界中のユーザーにとって本質的により楽しいアプリケーションを構築する力を与えてくれます。Suspense主導のデータフェッチングパラダイムへの移行は概念的な調整を必要としますが、コードの明瞭さ、パフォーマンス、およびユーザー満足度の面での利点は大きく、その投資に見合う価値があります。