T-CREATOR

Jotai 非同期チートシート:async atom/loadable/suspense の使い分け

Jotai 非同期チートシート:async atom/loadable/suspense の使い分け

React の状態管理ライブラリ「Jotai」を使っていると、非同期処理の扱い方で迷うことはありませんか。async atom を使えばいいのか、loadable を使うべきなのか、それとも Suspense と組み合わせるのが正解なのか。実は、これらは目的によって使い分けが必要なんです。

本記事では、Jotai の非同期処理における 3 つの主要なパターンを徹底的に解説します。それぞれの特徴や使い所、実装方法を具体例とともに見ていきましょう。

早見表:3 つのパターンの比較

まずは、3 つのパターンの特徴を表形式で確認しましょう。この早見表を参考に、自分のユースケースに合ったパターンを選択してください。

基本特性の比較

#パターンシンプルさローディング制御エラー制御Suspense 必須主な用途
1async atom★★★Suspense に委譲ErrorBoundaryはいシンプルな非同期データ取得
2loadable★★きめ細かい制御コンポーネント内いいえ部分的なローディング表示
3Suspense + async atom★★★境界単位で制御ErrorBoundaryはい宣言的な非同期 UI パターン

適用シーンの比較

#パターン最適な場面避けるべき場面実装の複雑度
1async atom単純な API 呼び出し個別のエラー表示が必要
2loadable複数データの独立表示全体を統一的にローディング表示
3Suspense + async atomページ全体・セクション単位の表示ローディング中も操作させたい場合低〜中

コード例の比較

各パターンの基本的な実装パターンを比較します。

async atom

typescriptconst userAtom = atom(async () => {
  const res = await fetch('https://api.example.com/user');
  return res.json();
});

// 使用例
<Suspense fallback={<div>読み込み中...</div>}>
  <UserComponent />
</Suspense>;

loadable

typescriptconst userAtom = atom(async () => {
  const res = await fetch('https://api.example.com/user');
  return res.json();
});
const loadableUserAtom = loadable(userAtom);

// 使用例(Suspense不要)
const [user] = useAtom(loadableUserAtom);
if (user.state === 'loading')
  return <div>読み込み中...</div>;
if (user.state === 'hasError') return <div>エラー</div>;
return <div>{user.data.name}</div>;

Suspense + async atom

typescriptconst userAtom = atom(async () => {
  const res = await fetch('https://api.example.com/user');
  return res.json();
});

// 使用例(複数のSuspense境界)
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Loading />}>
    <UserComponent />
  </Suspense>
</ErrorBoundary>;

判断フローチャート

どのパターンを選ぶべきか迷ったら、以下の質問に答えてください。

  1. ローディング中も他の操作をさせたい?
    • はい → loadable を検討
    • いいえ → 次の質問へ
  2. エラーを個別に表示したい?
    • はい → loadable を使用
    • いいえ → Suspense + async atom を使用
  3. 複数の非同期データを独立して扱いたい?
    • はい → loadable を使用
    • いいえ → async atom または Suspense + async atom を使用

背景

Jotai における非同期処理の仕組み

Jotai は、React の状態管理をシンプルにするために設計されたライブラリです。atom という小さな状態の単位を組み合わせて、アプリケーション全体の状態を管理できます。

非同期処理を扱う際、Jotai は以下の 3 つのアプローチを提供しています。

以下の図は、Jotai の非同期処理における基本的なデータフローを示しています。

mermaidflowchart TB
  component["Reactコンポーネント"]
  asyncAtom["async atom"]
  promise["Promise処理"]
  suspense["Suspense境界"]
  error["ErrorBoundary"]

  component -->|useAtom呼び出し| asyncAtom
  asyncAtom -->|非同期処理| promise
  promise -->|pending時| suspense
  promise -->|エラー時| error
  promise -->|成功時| component

Jotai では、atom の値として Promise を返すことができます。この仕組みにより、非同期データの取得をシンプルに記述できるのです。

3 つのパターンが生まれた理由

Jotai が複数の非同期処理パターンを用意している理由は、UI の要求が多様だからです。

  • async atom: 最もシンプルな非同期処理の実装方法
  • loadable: ローディング状態やエラーを細かく制御したい場合
  • Suspense: React 標準の非同期 UI パターンを活用したい場合

これらは互いに排他的ではなく、状況に応じて使い分けることで、より柔軟な UI 実装が可能になります。

課題

非同期処理の UI 表現における悩み

非同期データを扱う際、開発者は以下のような課題に直面します。

課題 1:ローディング状態の表現方法

データ取得中にスピナーを表示したい場合、どのように実装すればいいのでしょうか。Suspense を使うと全体がサスペンドされますが、部分的なローディング表示をしたい場合もありますよね。

課題 2:エラーハンドリングの粒度

エラーが発生した時、ErrorBoundary で全体をキャッチするのか、それとも個別のコンポーネント内でエラー表示するのか。要件によって適切な方法が異なります。

課題 3:複数の非同期 atom の組み合わせ

複数のデータソースから並行してデータを取得する場合、どれか 1 つでも失敗したら全体をエラーにすべきなのか、それとも取得できたものだけ表示すべきなのか。このような判断も必要です。

以下の図は、非同期処理における UI の状態遷移を示しています。

mermaidstateDiagram-v2
  [*] --> Idle: コンポーネント<br/>マウント
  Idle --> Loading: データ取得<br/>開始
  Loading --> Success: 取得成功
  Loading --> ErrorState: 取得失敗
  Success --> Loading: 再取得
  ErrorState --> Loading: リトライ
  Success --> [*]: アンマウント
  ErrorState --> [*]: アンマウント

このような状態管理を、どのパターンで実装するかが重要なポイントになります。

パターン選択の判断基準が不明瞭

Jotai の公式ドキュメントには各パターンの説明がありますが、「どの場面でどれを使うべきか」という判断基準は明確に示されていません。

結果として、開発者は試行錯誤しながら実装することになり、プロジェクト内で実装方法が統一されないという問題も発生します。

解決策

async atom:最もシンプルな非同期処理

async atom は、Jotai で非同期処理を実装する最も基本的な方法です。atom の値として Promise を返すだけで、非同期データを扱えるようになります。

特徴と適用場面

async atom は以下のような特徴があります。

  • シンプルさ: 実装が簡潔で理解しやすい
  • Suspense 連携: 自動的に React Suspense と連携する
  • 依存関係: 他の atom に依存した非同期処理が簡単に書ける

以下のようなケースで最適です。

#適用場面理由
1単純な API 呼び出し余計な状態管理が不要
2Suspense 境界内での使用自然なローディング表現
3他の atom に依存するデータ取得get 関数で依存解決が容易

基本的な実装方法

async atom の基本的な実装例を見ていきましょう。

まず、必要なパッケージをインポートします。

typescriptimport { atom } from 'jotai';

次に、async atom を定義します。atom の値として Promise を返す関数を指定するだけです。

typescript// ユーザー情報を取得するasync atom
const userAtom = atom(async () => {
  // APIからユーザー情報を取得
  const response = await fetch(
    'https://api.example.com/user'
  );

  // JSONとしてパース
  const data = await response.json();

  // 取得したデータを返す
  return data;
});

この async atom をコンポーネントで使用する場合は、通常の atom と同じようにuseAtomを使います。

typescriptimport { useAtom } from 'jotai';
import { Suspense } from 'react';

function UserProfile() {
  // async atomの値を取得(Promiseが解決されるまで待つ)
  const [user] = useAtom(userAtom);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

重要なのは、async atom を使うコンポーネントを Suspense で囲むことです。

typescriptfunction App() {
  return (
    // Suspense境界を設定し、ローディング中のUIを指定
    <Suspense fallback={<div>読み込み中...</div>}>
      <UserProfile />
    </Suspense>
  );
}

このように、async atom と Suspense を組み合わせることで、非同期処理のローディング状態を宣言的に表現できます。

他の atom に依存する場合

async atom の強力な機能の 1 つが、他の atom の値に依存した非同期処理を記述できることです。

例えば、選択されたユーザー ID に基づいて詳細情報を取得する場合を見てみましょう。

typescript// ユーザーIDを保持するatom
const userIdAtom = atom<number>(1);

このユーザー ID に基づいて、詳細情報を取得する async atom を定義します。

typescript// userIdAtomに依存するasync atom
const userDetailAtom = atom(async (get) => {
  // getで他のatomの値を取得
  const userId = get(userIdAtom);

  // 依存する値を使ってAPI呼び出し
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );
  const data = await response.json();

  return data;
});

get関数を使うことで、他の atom の値を参照できます。そして、依存元の atom が更新されると、自動的に再取得が行われます。

コンポーネント側では、ユーザー ID を変更するだけで詳細情報が自動更新されます。

typescriptfunction UserDetailView() {
  const [userId, setUserId] = useAtom(userIdAtom);
  const [detail] = useAtom(userDetailAtom);

  return (
    <div>
      {/* ユーザー切り替えボタン */}
      <button onClick={() => setUserId(userId + 1)}>
        次のユーザー
      </button>

      {/* 詳細情報表示 */}
      <h2>{detail.name}</h2>
      <p>{detail.bio}</p>
    </div>
  );
}

このパターンは、マスター・ディテール形式の UI や、フィルター条件に応じてデータを再取得する場合に非常に有効です。

loadable:きめ細かい状態制御

loadable は、非同期 atom の状態(loading、success、error)を明示的に扱えるユーティリティです。Suspense を使わずに、コンポーネント内でローディング状態やエラーを制御したい場合に最適です。

特徴と適用場面

loadable の主な特徴は以下の通りです。

  • 状態の可視化: loading、hasData、hasError の 3 つの状態を明示的に扱える
  • Suspense なしで動作: Suspense 境界が不要で、部分的なローディング表示が可能
  • 細かいエラー制御: エラーをコンポーネント内で直接ハンドリングできる

以下のようなケースで威力を発揮します。

#適用場面理由
1インラインローディング表示特定の要素だけスケルトン表示したい
2エラーの個別表示ErrorBoundary を使わずエラー表示したい
3複数の非同期データの部分表示一部失敗しても他は表示したい
4ローディング中も操作可能な UI全体をサスペンドしたくない

基本的な実装方法

まず、loadable をインポートします。

typescriptimport { atom } from 'jotai';
import { loadable } from 'jotai/utils';

async atom を定義し、それを loadable でラップします。

typescript// 通常のasync atom
const userDataAtom = atom(async () => {
  const response = await fetch(
    'https://api.example.com/user'
  );
  return response.json();
});

// loadableでラップ
const loadableUserAtom = loadable(userDataAtom);

loadable でラップされた atom は、以下の形式のオブジェクトを返します。

typescripttype Loadable<T> =
  | { state: 'loading' }
  | { state: 'hasData'; data: T }
  | { state: 'hasError'; error: unknown };

コンポーネントでの使用方法を見てみましょう。

typescriptimport { useAtom } from 'jotai';

function UserCard() {
  // loadableなatomの値を取得
  const [userLoadable] = useAtom(loadableUserAtom);

  // 状態に応じて表示を分岐
  if (userLoadable.state === 'loading') {
    return <div>ユーザー情報を読み込み中...</div>;
  }

  if (userLoadable.state === 'hasError') {
    return (
      <div className='error'>
        エラーが発生しました: {String(userLoadable.error)}
      </div>
    );
  }

  // 成功時はdataプロパティからデータを取得
  const user = userLoadable.data;

  return (
    <div className='user-card'>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

このように、Suspense 境界なしで非同期処理の状態を完全にコントロールできます。

スケルトン UI の実装例

loadable を使うと、スケルトン UI の実装が簡単になります。

まず、スケルトン用のコンポーネントを作成します。

typescriptfunction UserCardSkeleton() {
  return (
    <div className='user-card skeleton'>
      {/* 名前部分のスケルトン */}
      <div
        className='skeleton-line'
        style={{ width: '60%' }}
      ></div>

      {/* メール部分のスケルトン */}
      <div
        className='skeleton-line'
        style={{ width: '80%' }}
      ></div>
    </div>
  );
}

loadable の状態に応じて、スケルトン UI を表示します。

typescriptfunction UserCard() {
  const [userLoadable] = useAtom(loadableUserAtom);

  // ローディング中はスケルトン表示
  if (userLoadable.state === 'loading') {
    return <UserCardSkeleton />;
  }

  // エラー時の表示
  if (userLoadable.state === 'hasError') {
    return (
      <div className='user-card error-state'>
        <p>読み込みに失敗しました</p>
        <button onClick={() => window.location.reload()}>
          再読み込み
        </button>
      </div>
    );
  }

  // 実際のデータ表示
  return (
    <div className='user-card'>
      <h3>{userLoadable.data.name}</h3>
      <p>{userLoadable.data.email}</p>
    </div>
  );
}

この実装により、ページ全体をサスペンドせずに、個別のカードだけローディング表示できます。

複数の非同期データの並行表示

loadable の真価は、複数の非同期データを扱う際に発揮されます。

以下は、ユーザー情報と投稿一覧を別々に取得し、それぞれの状態を独立して表示する例です。

typescript// ユーザー情報のatom
const userAtom = atom(async () => {
  const res = await fetch('https://api.example.com/user');
  return res.json();
});

// 投稿一覧のatom
const postsAtom = atom(async () => {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
});

// それぞれをloadableでラップ
const loadableUserAtom = loadable(userAtom);
const loadablePostsAtom = loadable(postsAtom);

コンポーネントでは、それぞれの状態を独立して扱えます。

typescriptfunction Dashboard() {
  const [userLoadable] = useAtom(loadableUserAtom);
  const [postsLoadable] = useAtom(loadablePostsAtom);

  return (
    <div className='dashboard'>
      {/* ユーザー情報セクション */}
      <section>
        <h2>ユーザー情報</h2>
        {userLoadable.state === 'loading' && (
          <p>読み込み中...</p>
        )}
        {userLoadable.state === 'hasError' && (
          <p>エラーが発生しました</p>
        )}
        {userLoadable.state === 'hasData' && (
          <div>{userLoadable.data.name}</div>
        )}
      </section>

      {/* 投稿一覧セクション */}
      <section>
        <h2>投稿一覧</h2>
        {postsLoadable.state === 'loading' && (
          <p>読み込み中...</p>
        )}
        {postsLoadable.state === 'hasError' && (
          <p>エラーが発生しました</p>
        )}
        {postsLoadable.state === 'hasData' && (
          <ul>
            {postsLoadable.data.map((post) => (
              <li key={post.id}>{post.title}</li>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
}

このように、一方がエラーになっても他方は正常に表示できるという柔軟性があります。

Suspense:宣言的なローディング UI

Suspense は、React が提供する非同期 UI のための標準機能です。async atom と組み合わせることで、宣言的で直感的なローディング表現が可能になります。

特徴と適用場面

Suspense と async atom を組み合わせる利点は以下の通りです。

  • 宣言的な UI: ローディング状態の分岐処理が不要
  • コンポーネントの簡潔性: コンポーネント内では成功時の処理だけ書けばいい
  • 境界の柔軟性: Suspense 境界を複数設置して粒度を調整できる
  • React 標準: Next.js などのフレームワークとの親和性が高い

以下のような場合に最適です。

#適用場面理由
1ページ全体のローディング統一されたローディング体験
2ネストされた非同期コンポーネント自動的に最も近い Suspense 境界へ
3Server Components との連携Next.js App Router などで推奨
4シンプルなコンポーネント設計関心の分離が明確

以下の図は、Suspense 境界の配置パターンを示しています。

mermaidflowchart TD
  app["App"]
  suspense1["Suspense<br/>(全体用)"]
  suspense2["Suspense<br/>(サイドバー用)"]
  suspense3["Suspense<br/>(メイン用)"]
  header["Header<br/>(同期)"]
  sidebar["Sidebar<br/>(async)"]
  main["MainContent<br/>(async)"]

  app --> suspense1
  suspense1 --> header
  suspense1 --> suspense2
  suspense1 --> suspense3
  suspense2 --> sidebar
  suspense3 --> main

このように階層的に Suspense 境界を配置することで、きめ細かいローディング制御が可能になります。

基本的な実装方法

まず、必要なモジュールをインポートします。

typescriptimport { Suspense } from 'react';
import { atom, useAtom } from 'jotai';

async atom を定義します。

typescript// 記事データを取得するasync atom
const articleAtom = atom(async () => {
  const response = await fetch(
    'https://api.example.com/article/123'
  );
  return response.json();
});

コンポーネントでは、非同期処理が完了している前提でコードを書けます。

typescriptfunction ArticleDetail() {
  // atomの値を取得(Promiseが解決済みであることを前提)
  const [article] = useAtom(articleAtom);

  // 成功時の表示だけを記述すればいい
  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
    </article>
  );
}

そして、このコンポーネントを Suspense で囲みます。

typescriptfunction App() {
  return (
    <div>
      <header>
        <h1>ブログサイト</h1>
      </header>

      {/* Suspense境界を設定 */}
      <Suspense fallback={<div>記事を読み込み中...</div>}>
        <ArticleDetail />
      </Suspense>
    </div>
  );
}

このように、ArticleDetail コンポーネント内ではローディング状態を気にする必要がありません。すべて Suspense が面倒を見てくれます。

ErrorBoundary との組み合わせ

Suspense はローディング状態を扱いますが、エラーは ErrorBoundary で処理します。

まず、ErrorBoundary コンポーネントを作成します。

typescriptimport { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

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

  // エラーをキャッチしたら状態を更新
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      // エラー時のUIを表示
      return this.props.fallback;
    }

    return this.props.children;
  }
}

Suspense と ErrorBoundary を組み合わせて使用します。

typescriptfunction App() {
  return (
    <ErrorBoundary
      fallback={
        <div className='error-container'>
          <h2>エラーが発生しました</h2>
          <button onClick={() => window.location.reload()}>
            ページを再読み込み
          </button>
        </div>
      }
    >
      <Suspense fallback={<div>読み込み中...</div>}>
        <ArticleDetail />
      </Suspense>
    </ErrorBoundary>
  );
}

この構成により、ローディングとエラーの両方を宣言的に扱えます。

複数の Suspense 境界の配置

複雑なページでは、複数の Suspense 境界を配置することで、より良い UX を実現できます。

以下は、サイドバーとメインコンテンツを別々の Suspense 境界で囲む例です。

typescript// サイドバー用のasync atom
const sidebarAtom = atom(async () => {
  const res = await fetch(
    'https://api.example.com/sidebar'
  );
  return res.json();
});

// メインコンテンツ用のasync atom
const contentAtom = atom(async () => {
  const res = await fetch(
    'https://api.example.com/content'
  );
  return res.json();
});

各セクション用のコンポーネントを作成します。

typescriptfunction Sidebar() {
  const [data] = useAtom(sidebarAtom);
  return (
    <aside>
      <h2>カテゴリー</h2>
      <ul>
        {data.categories.map((cat) => (
          <li key={cat.id}>{cat.name}</li>
        ))}
      </ul>
    </aside>
  );
}

function MainContent() {
  const [data] = useAtom(contentAtom);
  return (
    <main>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </main>
  );
}

それぞれ独立した Suspense 境界で囲みます。

typescriptfunction Page() {
  return (
    <div className='layout'>
      {/* サイドバー用のSuspense */}
      <Suspense
        fallback={
          <div className='sidebar-skeleton'>...</div>
        }
      >
        <Sidebar />
      </Suspense>

      {/* メインコンテンツ用のSuspense */}
      <Suspense
        fallback={
          <div className='content-skeleton'>...</div>
        }
      >
        <MainContent />
      </Suspense>
    </div>
  );
}

この実装により、サイドバーの読み込みが完了すれば、メインコンテンツの完了を待たずに表示されます。ユーザーは早く情報を得られるため、体感速度が向上しますね。

使い分けの判断フロー

ここまで 3 つのパターンを見てきましたが、実際にどれを選ぶべきか迷うこともあるでしょう。以下のフローチャートを参考にしてください。

mermaidflowchart TD
  start["非同期データを<br/>取得したい"]
  q1{"ローディング中も<br/>他の操作をさせたい?"}
  q2{"エラーを<br/>個別表示したい?"}
  q3{"React標準の<br/>パターンを使いたい?"}

  useLoadable["loadable を使う"]
  useSuspense["Suspense + async atom"]
  useAsyncAtom["async atom のみ"]

  start --> q1
  q1 -->|はい| q2
  q1 -->|いいえ| q3
  q2 -->|はい| useLoadable
  q2 -->|いいえ| useSuspense
  q3 -->|はい| useSuspense
  q3 -->|いいえ| useAsyncAtom

判断のポイントをまとめると以下の通りです。

#パターン選択基準
1async atomシンプルな実装で、Suspense と組み合わせる前提
2loadableローディング・エラー状態を細かく制御したい
3Suspense + async atomReact 標準パターンで宣言的に書きたい

具体例

ケース 1:ユーザーダッシュボード(loadable 使用)

ユーザーダッシュボードでは、複数のウィジェット(統計情報、最近のアクティビティ、お知らせなど)を表示します。各ウィジェットは独立したデータソースから取得され、一部が失敗しても他は表示したいという要件があります。

このケースでは、loadable が最適です。

まず、各ウィジェット用の atom を定義します。

typescriptimport { atom } from 'jotai';
import { loadable } from 'jotai/utils';

// 統計情報を取得するatom
const statsAtom = atom(async () => {
  const res = await fetch('https://api.example.com/stats');
  if (!res.ok) throw new Error('統計情報の取得に失敗');
  return res.json();
});

// 最近のアクティビティを取得するatom
const activityAtom = atom(async () => {
  const res = await fetch(
    'https://api.example.com/activity'
  );
  if (!res.ok)
    throw new Error('アクティビティの取得に失敗');
  return res.json();
});

// お知らせを取得するatom
const notificationsAtom = atom(async () => {
  const res = await fetch(
    'https://api.example.com/notifications'
  );
  if (!res.ok) throw new Error('お知らせの取得に失敗');
  return res.json();
});

それぞれを loadable でラップします。

typescriptconst loadableStatsAtom = loadable(statsAtom);
const loadableActivityAtom = loadable(activityAtom);
const loadableNotificationsAtom = loadable(
  notificationsAtom
);

統計情報ウィジェットのコンポーネントを作成します。

typescriptfunction StatsWidget() {
  const [statsLoadable] = useAtom(loadableStatsAtom);

  return (
    <div className='widget stats-widget'>
      <h3>統計情報</h3>

      {statsLoadable.state === 'loading' && (
        <div className='skeleton'>
          <div className='skeleton-line'></div>
          <div className='skeleton-line'></div>
        </div>
      )}

      {statsLoadable.state === 'hasError' && (
        <div className='error-message'>
          データの読み込みに失敗しました
        </div>
      )}

      {statsLoadable.state === 'hasData' && (
        <div className='stats-content'>
          <div className='stat-item'>
            <span>訪問者数</span>
            <strong>{statsLoadable.data.visitors}</strong>
          </div>
          <div className='stat-item'>
            <span>ページビュー</span>
            <strong>{statsLoadable.data.pageViews}</strong>
          </div>
        </div>
      )}
    </div>
  );
}

アクティビティウィジェットも同様に実装します。

typescriptfunction ActivityWidget() {
  const [activityLoadable] = useAtom(loadableActivityAtom);

  return (
    <div className='widget activity-widget'>
      <h3>最近のアクティビティ</h3>

      {activityLoadable.state === 'loading' && (
        <div className='loading'>読み込み中...</div>
      )}

      {activityLoadable.state === 'hasError' && (
        <div className='error-message'>
          アクティビティを取得できませんでした
        </div>
      )}

      {activityLoadable.state === 'hasData' && (
        <ul className='activity-list'>
          {activityLoadable.data.items.map((item) => (
            <li key={item.id}>{item.message}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

お知らせウィジェットも実装します。

typescriptfunction NotificationsWidget() {
  const [notificationsLoadable] = useAtom(
    loadableNotificationsAtom
  );

  return (
    <div className='widget notifications-widget'>
      <h3>お知らせ</h3>

      {notificationsLoadable.state === 'loading' && (
        <div className='loading'>読み込み中...</div>
      )}

      {notificationsLoadable.state === 'hasError' && (
        <div className='error-message'>
          お知らせを取得できませんでした
        </div>
      )}

      {notificationsLoadable.state === 'hasData' && (
        <ul className='notifications-list'>
          {notificationsLoadable.data.items.map((item) => (
            <li key={item.id}>
              <strong>{item.title}</strong>
              <p>{item.message}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

最後に、ダッシュボード全体を構成します。

typescriptfunction Dashboard() {
  return (
    <div className='dashboard'>
      <h1>ダッシュボード</h1>

      <div className='widgets-grid'>
        <StatsWidget />
        <ActivityWidget />
        <NotificationsWidget />
      </div>
    </div>
  );
}

このように実装することで、統計情報の取得が失敗しても、アクティビティやお知らせは正常に表示されます。ユーザーは利用可能な情報をすぐに確認できるため、優れた UX を提供できますね。

ケース 2:記事詳細ページ(Suspense 使用)

ブログの記事詳細ページでは、記事本文とコメント一覧を表示します。記事本文は必須ですが、コメントは少し遅れて表示されても問題ありません。

このケースでは、ネストされた Suspense 境界を使った実装が効果的です。

まず、記事データとコメントデータの atom を定義します。

typescriptimport { atom } from 'jotai';

// URLパラメータから記事IDを取得する想定
const articleIdAtom = atom<string>('');

// 記事データを取得するasync atom
const articleAtom = atom(async (get) => {
  const articleId = get(articleIdAtom);

  const res = await fetch(
    `https://api.example.com/articles/${articleId}`
  );
  if (!res.ok) throw new Error('記事の取得に失敗しました');

  return res.json();
});

// コメントデータを取得するasync atom
const commentsAtom = atom(async (get) => {
  const articleId = get(articleIdAtom);

  // 意図的に遅延させてコメント取得を遅くする想定
  const res = await fetch(
    `https://api.example.com/articles/${articleId}/comments`
  );
  if (!res.ok)
    throw new Error('コメントの取得に失敗しました');

  return res.json();
});

記事本文を表示するコンポーネントを作成します。

typescriptimport { useAtom } from 'jotai';

function ArticleContent() {
  const [article] = useAtom(articleAtom);

  return (
    <article className='article-content'>
      <h1>{article.title}</h1>

      <div className='article-meta'>
        <span className='author'>{article.author}</span>
        <span className='date'>{article.publishedAt}</span>
      </div>

      <div
        className='article-body'
        dangerouslySetInnerHTML={{ __html: article.body }}
      />
    </article>
  );
}

コメント一覧を表示するコンポーネントも作成します。

typescriptfunction CommentsList() {
  const [comments] = useAtom(commentsAtom);

  return (
    <section className='comments-section'>
      <h2>コメント ({comments.length}件)</h2>

      <ul className='comments-list'>
        {comments.map((comment) => (
          <li key={comment.id} className='comment-item'>
            <div className='comment-header'>
              <strong>{comment.userName}</strong>
              <span className='comment-date'>
                {comment.createdAt}
              </span>
            </div>
            <p className='comment-body'>{comment.body}</p>
          </li>
        ))}
      </ul>
    </section>
  );
}

ページ全体を構成します。ここで重要なのは、Suspense 境界を階層的に配置することです。

typescriptimport { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function ArticlePage() {
  return (
    <div className='article-page'>
      {/* 記事本文用のSuspense境界 */}
      <ErrorBoundary
        fallback={
          <div className='error-state'>
            記事の読み込みに失敗しました
          </div>
        }
      >
        <Suspense
          fallback={
            <div className='article-skeleton'>
              <div className='skeleton-title'></div>
              <div className='skeleton-meta'></div>
              <div className='skeleton-body'></div>
            </div>
          }
        >
          <ArticleContent />
        </Suspense>
      </ErrorBoundary>

      {/* コメント用のSuspense境界(独立) */}
      <ErrorBoundary
        fallback={
          <div className='error-state'>
            コメントの読み込みに失敗しました
          </div>
        }
      >
        <Suspense
          fallback={
            <div className='comments-skeleton'>
              コメントを読み込み中...
            </div>
          }
        >
          <CommentsList />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

この実装により、記事本文が読み込まれた時点で表示され、その後コメントが追加で読み込まれます。ユーザーは記事をすぐに読み始められるため、待ち時間のストレスが軽減されます。

以下の図は、この実装における読み込みシーケンスを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Page as ページコンポーネント
  participant API as APIサーバー

  User->>Page: ページアクセス
  Page->>API: 記事データ要求
  Page->>API: コメントデータ要求

  Note over Page: 記事スケルトン表示<br/>コメントスケルトン表示

  API-->>Page: 記事データ返却
  Note over Page: 記事本文表示<br/>(コメントは<br/>まだスケルトン)

  User->>User: 記事を読み始める

  API-->>Page: コメントデータ返却
  Note over Page: コメント一覧表示

このように段階的にコンテンツが表示されることで、優れたユーザー体験を提供できます。

ケース 3:検索フォーム(async atom 使用)

検索フォームでは、ユーザーが入力したキーワードに基づいてリアルタイムで検索結果を表示します。キーワードが変わるたびに再検索する必要があります。

このケースでは、依存関係を持つ async atom が最適です。

まず、検索キーワードを保持する atom と、検索結果を取得する async atom を定義します。

typescriptimport { atom } from 'jotai';

// 検索キーワードを保持するatom
const searchKeywordAtom = atom<string>('');

// 検索結果を取得するasync atom(キーワードに依存)
const searchResultsAtom = atom(async (get) => {
  const keyword = get(searchKeywordAtom);

  // キーワードが空の場合は空配列を返す
  if (!keyword.trim()) {
    return [];
  }

  // APIで検索実行
  const res = await fetch(
    `https://api.example.com/search?q=${encodeURIComponent(
      keyword
    )}`
  );

  if (!res.ok) throw new Error('検索に失敗しました');

  return res.json();
});

検索フォームのコンポーネントを作成します。

typescriptimport { useAtom } from 'jotai';
import { useState } from 'react';

function SearchForm() {
  const [keyword, setKeyword] = useAtom(searchKeywordAtom);
  const [inputValue, setInputValue] = useState(keyword);

  // フォーム送信時にatomを更新
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setKeyword(inputValue);
  };

  return (
    <form onSubmit={handleSubmit} className='search-form'>
      <input
        type='text'
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder='キーワードを入力...'
        className='search-input'
      />

      <button type='submit' className='search-button'>
        検索
      </button>
    </form>
  );
}

検索結果を表示するコンポーネントを作成します。

typescriptfunction SearchResults() {
  const [results] = useAtom(searchResultsAtom);
  const [keyword] = useAtom(searchKeywordAtom);

  // キーワードが空の場合
  if (!keyword) {
    return (
      <div className='search-message'>
        キーワードを入力して検索してください
      </div>
    );
  }

  // 結果が0件の場合
  if (results.length === 0) {
    return (
      <div className='search-message'>
        「{keyword}」に一致する結果が見つかりませんでした
      </div>
    );
  }

  // 検索結果を表示
  return (
    <div className='search-results'>
      <p className='results-count'>
        {results.length}件の検索結果
      </p>

      <ul className='results-list'>
        {results.map((result) => (
          <li key={result.id} className='result-item'>
            <h3>{result.title}</h3>
            <p>{result.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

全体を統合します。Suspense で囲むことで、検索中のローディング表示を実現します。

typescriptimport { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function SearchPage() {
  return (
    <div className='search-page'>
      <h1>記事検索</h1>

      {/* 検索フォームは常に表示 */}
      <SearchForm />

      {/* 検索結果部分のみSuspense境界を設定 */}
      <ErrorBoundary
        fallback={
          <div className='error-state'>
            検索中にエラーが発生しました
          </div>
        }
      >
        <Suspense
          fallback={
            <div className='loading-state'>検索中...</div>
          }
        >
          <SearchResults />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

この実装により、以下のような動作が実現されます。

  1. ユーザーがキーワードを入力して検索ボタンをクリック
  2. searchKeywordAtomが更新される
  3. searchResultsAtomが自動的に再評価され、新しい検索を実行
  4. 検索中は「検索中...」と表示
  5. 結果が返ってきたら一覧を表示

キーワードが変わるたびに自動的に再検索されるため、コンポーネント側で明示的に再取得処理を書く必要がありません。これが async atom の依存関係の強みです。

実装パターン比較表

ここまで見てきた 3 つのケースを、特徴別に比較してみましょう。

#ケースパターンローディング表示エラー処理複数データ
1ダッシュボードloadableウィジェット単位個別表示部分的に表示可
2記事詳細Suspenseセクション単位ErrorBoundary段階的表示
3検索async atom全体サスペンドErrorBoundary単一データ

それぞれのパターンが適している理由をまとめます。

ダッシュボード(loadable)

  • 各ウィジェットが独立している
  • 一部が失敗しても他は表示したい
  • ローディング状態を細かく制御したい

記事詳細(Suspense)

  • コンテンツが階層的な構造を持つ
  • 優先度に応じて段階的に表示したい
  • React 標準のパターンを使いたい

検索(async atom)

  • 依存関係がシンプル
  • 検索キーワードに応じて自動再取得したい
  • コンポーネントをシンプルに保ちたい

このように、要件に応じて最適なパターンを選択することが重要です。

まとめ

Jotai の非同期処理における 3 つのパターン、async atom、loadable、Suspense の使い分けについて解説してきました。

それぞれのパターンには明確な適用場面があります。

async atom は、シンプルな非同期処理の基本形です。他の atom に依存した動的なデータ取得に最適で、コードが簡潔に保てます。

loadable は、ローディング状態やエラーを細かく制御したい場合に威力を発揮します。複数の非同期データを独立して扱いたいダッシュボードのような UI に最適です。

Suspense は、React 標準の宣言的なパターンを活用できます。コンポーネント内では成功時の処理だけを記述すればよく、ローディングやエラーは境界で処理できます。

重要なのは、これらのパターンが互いに排他的ではないということです。同じアプリケーション内で、画面や要件に応じて使い分けることで、より柔軟で保守性の高いコードを書けます。

非同期処理の実装で迷ったときは、以下の基準で判断してください。

  • シンプルさを優先: async atom + Suspense
  • 細かい制御が必要: loadable
  • 段階的な表示: ネストされた Suspense 境界

これらのパターンをマスターすることで、Jotai を使った状態管理がより快適になるはずです。ぜひ実際のプロジェクトで試してみてください。

関連リンク