Jotai 非同期チートシート:async atom/loadable/suspense の使い分け
React の状態管理ライブラリ「Jotai」を使っていると、非同期処理の扱い方で迷うことはありませんか。async atom を使えばいいのか、loadable を使うべきなのか、それとも Suspense と組み合わせるのが正解なのか。実は、これらは目的によって使い分けが必要なんです。
本記事では、Jotai の非同期処理における 3 つの主要なパターンを徹底的に解説します。それぞれの特徴や使い所、実装方法を具体例とともに見ていきましょう。
早見表:3 つのパターンの比較
まずは、3 つのパターンの特徴を表形式で確認しましょう。この早見表を参考に、自分のユースケースに合ったパターンを選択してください。
基本特性の比較
| # | パターン | シンプルさ | ローディング制御 | エラー制御 | Suspense 必須 | 主な用途 |
|---|---|---|---|---|---|---|
| 1 | async atom | ★★★ | Suspense に委譲 | ErrorBoundary | はい | シンプルな非同期データ取得 |
| 2 | loadable | ★★ | きめ細かい制御 | コンポーネント内 | いいえ | 部分的なローディング表示 |
| 3 | Suspense + async atom | ★★★ | 境界単位で制御 | ErrorBoundary | はい | 宣言的な非同期 UI パターン |
適用シーンの比較
| # | パターン | 最適な場面 | 避けるべき場面 | 実装の複雑度 |
|---|---|---|---|---|
| 1 | async atom | 単純な API 呼び出し | 個別のエラー表示が必要 | 低 |
| 2 | loadable | 複数データの独立表示 | 全体を統一的にローディング表示 | 中 |
| 3 | Suspense + 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>;
判断フローチャート
どのパターンを選ぶべきか迷ったら、以下の質問に答えてください。
- ローディング中も他の操作をさせたい?
- はい → loadable を検討
- いいえ → 次の質問へ
- エラーを個別に表示したい?
- はい → loadable を使用
- いいえ → Suspense + async atom を使用
- 複数の非同期データを独立して扱いたい?
- はい → 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 呼び出し | 余計な状態管理が不要 |
| 2 | Suspense 境界内での使用 | 自然なローディング表現 |
| 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 境界へ |
| 3 | Server 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
判断のポイントをまとめると以下の通りです。
| # | パターン | 選択基準 |
|---|---|---|
| 1 | async atom | シンプルな実装で、Suspense と組み合わせる前提 |
| 2 | loadable | ローディング・エラー状態を細かく制御したい |
| 3 | Suspense + async atom | React 標準パターンで宣言的に書きたい |
具体例
ケース 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>
);
}
この実装により、以下のような動作が実現されます。
- ユーザーがキーワードを入力して検索ボタンをクリック
searchKeywordAtomが更新されるsearchResultsAtomが自動的に再評価され、新しい検索を実行- 検索中は「検索中...」と表示
- 結果が返ってきたら一覧を表示
キーワードが変わるたびに自動的に再検索されるため、コンポーネント側で明示的に再取得処理を書く必要がありません。これが 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 を使った状態管理がより快適になるはずです。ぜひ実際のプロジェクトで試してみてください。
関連リンク
articleJotai 非同期チートシート:async atom/loadable/suspense の使い分け
articlejotai × TypeScript 型推論を極める実戦のための環境設定術
articleJotai のリアクティブ思考法:コンポーネントから状態を切り離す設計哲学
articleJotai 運用ガイド:命名規約・debugLabel・依存グラフ可視化の標準化
articleJotai のスケール特性を実測:コンポ数 × 更新頻度 × 派生深さのベンチ
articleJotai が再レンダリング地獄に?依存グラフの暴走を止める診断手順
articleMCP サーバー クイックリファレンス:Tool 宣言・リクエスト/レスポンス・エラーコード・ヘッダー早見表
articleMotion(旧 Framer Motion)× GSAP 併用/置換の判断基準:大規模アニメの最適解を探る
articleLodash を使う/使わない判断基準:2025 年のネイティブ API と併用戦略
articleMistral の始め方:API キー発行から最初のテキスト生成まで 5 分クイックスタート
articleLlamaIndex で最小 RAG を 10 分で構築:Loader→Index→Query Engine 一気通貫
articleJavaScript structuredClone 徹底検証:JSON 方式や cloneDeep との速度・互換比較
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来