React SuspenseとLazy Loading: 実践ガイド

Shunku

React Suspenseは非同期操作を宣言的に処理するためのメカニズムです。React.lazy()と組み合わせることで、アプリの初期ロード時間を大幅に改善できるコード分割が可能になります。

Suspenseとは?

Suspenseは、何かのロードを待っている間のローディング状態を宣言的に指定できます:

<Suspense fallback={<Spinner />}>
  <SomeComponent />
</Suspense>

SomeComponentがまだ準備できていない場合(lazy loadされているかデータをフェッチ中)、Reactは準備ができるまでfallbackを表示します。

flowchart TD
    A[コンポーネントをレンダリング] --> B{準備完了?}
    B -->|はい| C[コンポーネントを表示]
    B -->|いいえ| D[フォールバックを表示]
    D --> E[コンポーネントがロード中...]
    E --> C

    style D fill:#f59e0b,color:#fff
    style C fill:#10b981,color:#fff

コンポーネントのLazy Loading

基本的な使い方

React.lazy()は、コンポーネントが必要になった時だけロードできます:

import { lazy, Suspense } from 'react';

// コンポーネントはレンダリングされるまでロードされない
const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <div>
      <h1>ダッシュボード</h1>
      <Suspense fallback={<div>チャートをロード中...</div>}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}

HeavyChartコンポーネントのコードは、Dashboardコンポーネントが初めてレンダリングするまでダウンロードされません。

ルートベースのコード分割

lazy loadingの最も効果的な使用はルートレベルです:

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 各ルートは独自のバンドルをロード
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

ユーザーは訪問するページのコードだけをダウンロードします。

名前付きエクスポート

React.lazy()はdefaultエクスポートでのみ動作します。名前付きエクスポートの場合は、中間モジュールを作成します:

// ManyComponents.jsは複数のコンポーネントをエクスポート
export const ComponentA = () => <div>A</div>;
export const ComponentB = () => <div>B</div>;

// 中間インポートを使用
const ComponentA = lazy(() =>
  import('./ManyComponents').then(module => ({ default: module.ComponentA }))
);

Suspense境界

複数のSuspense境界

詳細なローディング状態のためにSuspense境界をネストできます:

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      <main>
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>
        <Suspense fallback={<ContentSkeleton />}>
          <MainContent />
        </Suspense>
      </main>
    </Suspense>
  );
}
flowchart TD
    A["ページSuspense"] --> B[Headerがロード]
    A --> C["サイドバーSuspense"]
    A --> D["コンテンツSuspense"]
    C --> E[サイドバーがロード中...]
    D --> F[コンテンツがロード中...]
    E --> G[サイドバー準備完了]
    F --> H[コンテンツ準備完了]

    style E fill:#f59e0b,color:#fff
    style F fill:#f59e0b,color:#fff
    style G fill:#10b981,color:#fff
    style H fill:#10b981,color:#fff

Suspense境界の配置場所

  • 高すぎる:ユーザーは空白ページを長く見る
  • 低すぎる:ローディングスピナーが多すぎて不快な体験
  • ちょうど良い:ユーザーの期待に合ったローディング状態
// 高すぎる - ページ全体がローダーを表示
<Suspense fallback={<FullPageLoader />}>
  <EntireApp />
</Suspense>

// 低すぎる - すべてのコンポーネントが独自のローダーを持つ
<div>
  <Suspense fallback={<Spinner />}><Header /></Suspense>
  <Suspense fallback={<Spinner />}><Nav /></Suspense>
  <Suspense fallback={<Spinner />}><Content /></Suspense>
  <Suspense fallback={<Spinner />}><Footer /></Suspense>
</div>

// より良い - 論理的なグループ化
<div>
  <Header /> {/* 常にすぐにロード */}
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent /> {/* メインエリアは一緒にロード */}
  </Suspense>
</div>

エラーハンドリング

ロード失敗を処理するためにSuspenseと一緒にError Boundaryを使用します:

import { Component } from 'react';

class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h2>問題が発生しました。</h2>
          <button onClick={() => this.setState({ hasError: false })}>
            再試行
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// 使用例
function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<Spinner />}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

コンポーネントのプリロード

必要になる前にlazyコンポーネントをプリロードできます:

const HeavyComponent = lazy(() => import('./HeavyComponent'));

// ホバー時にプリロード
function Navigation() {
  const handleMouseEnter = () => {
    // コンポーネントのロードを開始
    import('./HeavyComponent');
  };

  return (
    <Link
      to="/heavy"
      onMouseEnter={handleMouseEnter}
    >
      重いページへ
    </Link>
  );
}

または専用のプリロード関数を使用:

// プリロード可能なlazyコンポーネントを作成
function lazyWithPreload(factory) {
  const Component = lazy(factory);
  Component.preload = factory;
  return Component;
}

const Dashboard = lazyWithPreload(() => import('./Dashboard'));

// ユーザーがそこにナビゲートするかもしれない時にプリロード
function Nav() {
  return (
    <nav onMouseEnter={() => Dashboard.preload()}>
      <Link to="/dashboard">ダッシュボード</Link>
    </nav>
  );
}

データフェッチングのためのSuspense

主にコード分割に使用されますが、Suspenseは適切な統合でデータフェッチングも処理できます:

React Query / TanStack Queryと

import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  // このコンポーネントはフェッチ中にサスペンドする
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={123} />
    </Suspense>
  );
}

Reactのuseフックと(React 19+)

import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

function App() {
  const userPromise = fetchUser(123); // Promiseを返す

  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

ベストプラクティス

1. 意味のあるローディング状態を表示

// 悪い:汎用的なスピナー
<Suspense fallback={<Spinner />}>
  <DataTable />
</Suspense>

// 良い:コンテンツに合ったスケルトン
<Suspense fallback={<DataTableSkeleton rows={10} />}>
  <DataTable />
</Suspense>

2. レイアウトシフトを避ける

フォールバックをコンテンツと同じサイズにする:

function CardSkeleton() {
  return (
    <div className="card" style={{ height: 200 }}>
      <div className="skeleton-title" />
      <div className="skeleton-content" />
    </div>
  );
}

3. 緊急でない更新にはstartTransitionを使用

素早いナビゲーションでローディング状態を防ぐ:

import { startTransition } from 'react';

function Tabs({ tabs }) {
  const [tab, setTab] = useState(tabs[0]);

  function selectTab(nextTab) {
    // 素早い遷移ではSuspenseフォールバックを表示しない
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <div>
      <TabButtons tabs={tabs} onSelect={selectTab} />
      <Suspense fallback={<TabSkeleton />}>
        <TabContent tab={tab} />
      </Suspense>
    </div>
  );
}

4. 初期バンドルを小さく保つ

ルートと大きな機能を積極的に分割:

// ルートで分割
const routes = {
  home: lazy(() => import('./pages/Home')),
  dashboard: lazy(() => import('./pages/Dashboard')),
  settings: lazy(() => import('./pages/Settings')),
};

// 大きな機能を分割
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
const DataVisualization = lazy(() => import('./components/DataVisualization'));

まとめ

概念 説明
React.lazy() コンポーネントを動的にインポート
<Suspense> ロード中にフォールバックを表示
コード分割 必要な時だけコードをロード
Error Boundary ロード失敗を処理
プリロード 必要になる前にロードを開始

重要なポイント:

  • ルートレベルのコード分割にReact.lazy()を使用
  • 論理的なUIブレークポイントにSuspense境界を配置
  • ロードするコンテンツの形状に合ったスケルトンを表示
  • ロード失敗を処理するためにError Boundaryを使用
  • ユーザーが次に必要としそうなコンポーネントをプリロード
  • 素早いロードでちらつきを避けるためにstartTransitionを使用

SuspenseとLazy Loadingは、パフォーマンスの高いReactアプリケーションを構築するための必須ツールです。ローディング中の良いユーザー体験を維持しながら、より小さな初期バンドルを配信できます。

参考文献