T-CREATOR

ローディングとエラー状態をスマートに分離。Jotai の loadable ユーティリティ徹底活用ガイド

ローディングとエラー状態をスマートに分離。Jotai の loadable ユーティリティ徹底活用ガイド

現代の Web アプリケーション開発において、非同期処理は避けて通れません。API からのデータ取得、画像の読み込み、ファイルのアップロードなど、ユーザーが快適に操作できるアプリを作るためには、これらの処理を適切に管理する必要があります。

しかし、従来の状態管理手法では「ローディング中」「データ取得完了」「エラー発生」といった状態が複雑に絡み合い、コードの保守性やユーザー体験の向上が困難でした。そんな課題を解決するのが、Jotai のloadableユーティリティです。

loadableを使うことで、非同期処理の状態を宣言的かつ直感的に管理できるようになります。本記事では、loadable の基本概念から実践的な活用方法、さらには高度なテクニックまで、体系的に解説していきます。

非同期処理における状態管理の課題

従来のアプローチの限界

React 開発において、非同期処理の状態管理は長年の課題でした。従来のアプローチには以下のような問題がありました。

useState による状態管理の複雑化

typescript// 従来のアプローチ:複数の状態を手動で管理
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetchUser(userId)
      .then((userData) => {
        setUser(userData);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!user) return <div>ユーザーが見つかりません</div>;

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

このアプローチでは、loadingerroruserの 3 つの状態を手動で同期させる必要があり、状態の不整合が発生しやすくなります。

Redux Toolkit での非同期処理

typescript// Redux Toolkit の createAsyncThunk
const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string, { rejectWithValue }) => {
    try {
      const response = await api.getUser(userId);
      return response.data;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    loading: false,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload;
      });
  },
});

Redux Toolkit では記述量が多くなり、小規模なアプリケーションには過度に複雑になってしまいます。

ローディングとエラー状態が混在する問題

従来のアプローチでは、以下のような問題が頻繁に発生していました。

状態の不整合

問題発生例影響
ローディング状態のリセット忘れsetLoading(false)の呼び忘れ永続的なローディング表示
エラー状態の初期化漏れ再実行時のsetError(null)忘れ古いエラーメッセージの表示
競合状態複数の API リクエストが同時実行予期しないデータの上書き

実際のエラー例

javaError: Cannot read property 'name' of null
    at UserProfile (UserProfile.tsx:23:34)
    at renderWithHooks (react-dom.development.js:14985:18)

このエラーは、ローディング完了後にusernullのままになってしまう典型的なケースです。

vbnetWarning: Can't perform a React state update on an unmounted component
    at UserProfile (UserProfile.tsx:15:12)

コンポーネントがアンマウントされた後に API レスポンスが返ってきて、状態更新を試みる際に発生するエラーです。

loadable ユーティリティとは

loadable の基本概念

Jotai のloadableは、非同期処理の状態を統一的に管理するためのユーティリティです。非同期 atom をloadableでラップすることで、処理の状態を明確に分離できます。

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

// 非同期atomの定義
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Error(
      `Failed to fetch user: ${response.statusText}`
    );
  }
  return response.json();
});

// loadableでラップ
const userLoadableAtom = loadable(userAtom);

loadableにより、非同期処理は以下の 3 つの状態に分類されます:

状態プロパティ説明
Loadingstate: 'loading'処理実行中
Successstate: 'hasData'処理成功、データあり
Errorstate: 'hasError'処理失敗、エラー情報あり

Suspense との違い

React の Suspense と loadable は、どちらも非同期処理を扱いますが、アプローチが異なります。

Suspense アプローチ

typescriptfunction UserProfile() {
  const user = useAtomValue(userAtom); // Promise が throw される
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <ErrorBoundary
        fallback={<div>エラーが発生しました</div>}
      >
        <UserProfile />
      </ErrorBoundary>
    </Suspense>
  );
}

loadable アプローチ

typescriptfunction UserProfile() {
  const userLoadable = useAtomValue(userLoadableAtom);

  if (userLoadable.state === 'loading') {
    return <div>読み込み中...</div>;
  }

  if (userLoadable.state === 'hasError') {
    return <div>エラー: {userLoadable.error.message}</div>;
  }

  return <div>{userLoadable.data.name}</div>;
}
特徴Suspenseloadable
エラーハンドリングErrorBoundary が必要コンポーネント内で処理
ローディング表示親コンポーネントで制御コンポーネント内で制御
細かい制御制限あり柔軟性が高い
学習コスト高い低い

loadable が解決する 3 つの問題

1. 状態管理の複雑性解消

typescript// Before: 複数の状態を手動管理
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// After: 単一の状態オブジェクト
const loadable = useAtomValue(dataLoadableAtom);
// loadable.state, loadable.data, loadable.error が自動管理

2. 型安全性の向上

typescript// TypeScript での型推論が自動で効く
const userLoadable = useAtomValue(userLoadableAtom);

if (userLoadable.state === 'hasData') {
  // userLoadable.data は User 型として推論される
  console.log(userLoadable.data.name); // 型安全
}

if (userLoadable.state === 'hasError') {
  // userLoadable.error は Error 型として推論される
  console.log(userLoadable.error.message); // 型安全
}

3. 競合状態の自動解決

typescript// loadable は最新のリクエスト結果のみを保持
const searchResultsAtom = atom(async (get) => {
  const query = get(searchQueryAtom);
  const response = await fetch(`/api/search?q=${query}`);
  return response.json();
});

const searchLoadableAtom = loadable(searchResultsAtom);
// クエリが変更されても、最新の結果のみが反映される

loadable の基本的な使い方

セットアップと導入

まず、プロジェクトに Jotai を導入します:

bashyarn add jotai

TypeScript を使用する場合、型定義も自動でインストールされます。

基本的なプロジェクト構成

csssrc/
├── atoms/
│   ├── userAtoms.ts
│   └── index.ts
├── components/
│   ├── UserProfile.tsx
│   └── LoadingSpinner.tsx
├── hooks/
│   └── useUserData.ts
└── types/
    └── user.ts

型定義の作成

typescript// src/types/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
  createdAt: string;
}

export interface ApiError {
  message: string;
  code: string;
  statusCode: number;
}

基本的な loadable パターン

シンプルなデータ取得

typescript// src/atoms/userAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';
import { User } from '../types/user';

// ユーザーIDを管理するatom
export const userIdAtom = atom<string>('');

// ユーザーデータを取得する非同期atom
export const userAtom = atom(async (get): Promise<User> => {
  const userId = get(userIdAtom);

  if (!userId) {
    throw new Error('User ID is required');
  }

  const response = await fetch(`/api/users/${userId}`);

  if (!response.ok) {
    throw new Error(
      `HTTP ${response.status}: ${response.statusText}`
    );
  }

  const userData = await response.json();
  return userData;
});

// loadableでラップ
export const userLoadableAtom = loadable(userAtom);

コンポーネントでの使用

typescript// src/components/UserProfile.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  userLoadableAtom,
  userIdAtom,
} from '../atoms/userAtoms';

export function UserProfile() {
  const userLoadable = useAtomValue(userLoadableAtom);
  const setUserId = useSetAtom(userIdAtom);

  const handleUserChange = (newUserId: string) => {
    setUserId(newUserId);
  };

  return (
    <div className='user-profile'>
      <select
        onChange={(e) => handleUserChange(e.target.value)}
      >
        <option value=''>ユーザーを選択</option>
        <option value='1'>ユーザー1</option>
        <option value='2'>ユーザー2</option>
      </select>

      {userLoadable.state === 'loading' && (
        <div className='loading'>
          <span>ユーザー情報を読み込み中...</span>
        </div>
      )}

      {userLoadable.state === 'hasError' && (
        <div className='error'>
          <h3>エラーが発生しました</h3>
          <p>{userLoadable.error.message}</p>
          <button onClick={() => handleUserChange('')}>
            リセット
          </button>
        </div>
      )}

      {userLoadable.state === 'hasData' && (
        <div className='user-data'>
          <h2>{userLoadable.data.name}</h2>
          <p>{userLoadable.data.email}</p>
          <small>
            登録日: {userLoadable.data.createdAt}
          </small>
        </div>
      )}
    </div>
  );
}

状態の種類(loading, hasData, hasError)

loadable は以下の 3 つの状態を持ちます:

loading 状態

typescriptinterface LoadingState {
  state: 'loading';
}

非同期処理が実行中の状態です。この状態ではdataerrorプロパティは存在しません。

typescriptif (loadable.state === 'loading') {
  // loadable.data → TypeScriptエラー
  // loadable.error → TypeScriptエラー
  console.log('処理中です...');
}

hasData 状態

typescriptinterface HasDataState<T> {
  state: 'hasData';
  data: T;
}

非同期処理が成功し、データが取得できた状態です。

typescriptif (loadable.state === 'hasData') {
  // loadable.data は T 型として推論される
  console.log('データ:', loadable.data);
  // loadable.error → TypeScriptエラー
}

hasError 状態

typescriptinterface HasErrorState {
  state: 'hasError';
  error: unknown;
}

非同期処理が失敗し、エラーが発生した状態です。

typescriptif (loadable.state === 'hasError') {
  // loadable.error にエラー情報が格納される
  console.error('エラー:', loadable.error);
  // loadable.data → TypeScriptエラー
}

状態判定のヘルパー関数

より便利に状態を判定するためのヘルパー関数を作成できます:

typescript// src/utils/loadableUtils.ts
import { Loadable } from 'jotai/utils';

export function isLoading<T>(
  loadable: Loadable<T>
): boolean {
  return loadable.state === 'loading';
}

export function hasData<T>(
  loadable: Loadable<T>
): loadable is { state: 'hasData'; data: T } {
  return loadable.state === 'hasData';
}

export function hasError<T>(
  loadable: Loadable<T>
): loadable is { state: 'hasError'; error: unknown } {
  return loadable.state === 'hasError';
}

// 使用例
const userLoadable = useAtomValue(userLoadableAtom);

if (hasData(userLoadable)) {
  // TypeScript が userLoadable.data の存在を保証
  console.log(userLoadable.data.name);
}

実践的な活用例

API データの取得パターン

RESTful API の統合

typescript// src/atoms/apiAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

// APIベースURLの管理
const API_BASE = 'https://jsonplaceholder.typicode.com';

// 投稿一覧を取得するatom
export const postsAtom = atom(async (): Promise<Post[]> => {
  const response = await fetch(`${API_BASE}/posts`);

  if (!response.ok) {
    throw new Error(
      `Failed to fetch posts: ${response.status} ${response.statusText}`
    );
  }

  return response.json();
});

export const postsLoadableAtom = loadable(postsAtom);

// 特定の投稿を取得するatom
export const postIdAtom = atom<number | null>(null);

export const currentPostAtom = atom(
  async (get): Promise<Post | null> => {
    const postId = get(postIdAtom);

    if (!postId) {
      return null;
    }

    const response = await fetch(
      `${API_BASE}/posts/${postId}`
    );

    if (!response.ok) {
      throw new Error(`Post not found: ${response.status}`);
    }

    return response.json();
  }
);

export const currentPostLoadableAtom =
  loadable(currentPostAtom);

エラーハンドリングの強化

typescript// src/utils/apiErrors.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public response?: Response
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

// src/atoms/enhancedApiAtoms.ts
export const enhancedUserAtom = atom(
  async (get): Promise<User> => {
    const userId = get(userIdAtom);

    try {
      const response = await fetch(`/api/users/${userId}`);

      if (!response.ok) {
        // HTTPステータスコードに応じたエラー処理
        switch (response.status) {
          case 404:
            throw new ApiError(
              'ユーザーが見つかりません',
              404,
              response
            );
          case 403:
            throw new ApiError(
              'アクセス権限がありません',
              403,
              response
            );
          case 500:
            throw new ApiError(
              'サーバーエラーが発生しました',
              500,
              response
            );
          default:
            throw new ApiError(
              `予期しないエラーが発生しました: ${response.statusText}`,
              response.status,
              response
            );
        }
      }

      return response.json();
    } catch (error) {
      if (error instanceof ApiError) {
        throw error;
      }

      // ネットワークエラーなど
      throw new ApiError(
        'ネットワークエラーが発生しました',
        0
      );
    }
  }
);

画像ローディングの管理

画像の読み込み状態を管理する実装例です:

typescript// src/atoms/imageAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

interface ImageData {
  url: string;
  width: number;
  height: number;
  loaded: boolean;
}

// 画像URLを管理するatom
export const imageUrlAtom = atom<string>('');

// 画像の読み込みを行うatom
export const imageAtom = atom(
  async (get): Promise<ImageData> => {
    const url = get(imageUrlAtom);

    if (!url) {
      throw new Error('画像URLが指定されていません');
    }

    return new Promise((resolve, reject) => {
      const img = new Image();

      img.onload = () => {
        resolve({
          url,
          width: img.naturalWidth,
          height: img.naturalHeight,
          loaded: true,
        });
      };

      img.onerror = () => {
        reject(
          new Error(`画像の読み込みに失敗しました: ${url}`)
        );
      };

      // タイムアウト設定(10秒)
      setTimeout(() => {
        reject(
          new Error('画像の読み込みがタイムアウトしました')
        );
      }, 10000);

      img.src = url;
    });
  }
);

export const imageLoadableAtom = loadable(imageAtom);

画像コンポーネントの実装

typescript// src/components/LoadableImage.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  imageLoadableAtom,
  imageUrlAtom,
} from '../atoms/imageAtoms';

interface LoadableImageProps {
  src: string;
  alt: string;
  className?: string;
}

export function LoadableImage({
  src,
  alt,
  className,
}: LoadableImageProps) {
  const setImageUrl = useSetAtom(imageUrlAtom);
  const imageLoadable = useAtomValue(imageLoadableAtom);

  // src が変更された時にatom を更新
  React.useEffect(() => {
    setImageUrl(src);
  }, [src, setImageUrl]);

  if (imageLoadable.state === 'loading') {
    return (
      <div className={`image-loading ${className || ''}`}>
        <div className='loading-spinner' />
        <span>画像を読み込み中...</span>
      </div>
    );
  }

  if (imageLoadable.state === 'hasError') {
    return (
      <div className={`image-error ${className || ''}`}>
        <div className='error-icon'>⚠️</div>
        <span>画像の読み込みに失敗しました</span>
        <small>{imageLoadable.error.message}</small>
      </div>
    );
  }

  return (
    <img
      src={imageLoadable.data.url}
      alt={alt}
      width={imageLoadable.data.width}
      height={imageLoadable.data.height}
      className={className}
    />
  );
}

複数の非同期処理の並列実行

複数の API を並列で実行し、それぞれの状態を管理する例:

typescript// src/atoms/parallelAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

// ユーザー情報取得
const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  return response.json();
});

// 投稿一覧取得
const postsAtom = atom(async () => {
  const response = await fetch('/api/posts');
  return response.json();
});

// 通知一覧取得
const notificationsAtom = atom(async () => {
  const response = await fetch('/api/notifications');
  return response.json();
});

// 各atomをloadableでラップ
export const userLoadableAtom = loadable(userAtom);
export const postsLoadableAtom = loadable(postsAtom);
export const notificationsLoadableAtom = loadable(
  notificationsAtom
);

// 全体の読み込み状態を管理するatom
export const dashboardLoadingAtom = atom((get) => {
  const userLoadable = get(userLoadableAtom);
  const postsLoadable = get(postsLoadableAtom);
  const notificationsLoadable = get(
    notificationsLoadableAtom
  );

  // いずれかが loading 状態の場合は true
  return (
    userLoadable.state === 'loading' ||
    postsLoadable.state === 'loading' ||
    notificationsLoadable.state === 'loading'
  );
});

// エラー状態をまとめるatom
export const dashboardErrorsAtom = atom((get) => {
  const userLoadable = get(userLoadableAtom);
  const postsLoadable = get(postsLoadableAtom);
  const notificationsLoadable = get(
    notificationsLoadableAtom
  );

  const errors: string[] = [];

  if (userLoadable.state === 'hasError') {
    errors.push(
      `ユーザー情報: ${userLoadable.error.message}`
    );
  }
  if (postsLoadable.state === 'hasError') {
    errors.push(`投稿一覧: ${postsLoadable.error.message}`);
  }
  if (notificationsLoadable.state === 'hasError') {
    errors.push(
      `通知一覧: ${notificationsLoadable.error.message}`
    );
  }

  return errors;
});

ダッシュボードコンポーネント

typescript// src/components/Dashboard.tsx
import { useAtomValue } from 'jotai';
import {
  userLoadableAtom,
  postsLoadableAtom,
  notificationsLoadableAtom,
  dashboardLoadingAtom,
  dashboardErrorsAtom,
} from '../atoms/parallelAtoms';

export function Dashboard() {
  const userLoadable = useAtomValue(userLoadableAtom);
  const postsLoadable = useAtomValue(postsLoadableAtom);
  const notificationsLoadable = useAtomValue(
    notificationsLoadableAtom
  );
  const isLoading = useAtomValue(dashboardLoadingAtom);
  const errors = useAtomValue(dashboardErrorsAtom);

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

      {isLoading && (
        <div className='global-loading'>
          データを読み込み中...
        </div>
      )}

      {errors.length > 0 && (
        <div className='error-summary'>
          <h3>エラーが発生しました</h3>
          <ul>
            {errors.map((error, index) => (
              <li key={index}>{error}</li>
            ))}
          </ul>
        </div>
      )}

      <div className='dashboard-grid'>
        {/* ユーザー情報セクション */}
        <section className='user-section'>
          <h2>ユーザー情報</h2>
          {userLoadable.state === 'hasData' && (
            <div>{userLoadable.data.name}</div>
          )}
        </section>

        {/* 投稿セクション */}
        <section className='posts-section'>
          <h2>最新の投稿</h2>
          {postsLoadable.state === 'hasData' && (
            <ul>
              {postsLoadable.data
                .slice(0, 5)
                .map((post) => (
                  <li key={post.id}>{post.title}</li>
                ))}
            </ul>
          )}
        </section>

        {/* 通知セクション */}
        <section className='notifications-section'>
          <h2>通知</h2>
          {notificationsLoadable.state === 'hasData' && (
            <div>
              {notificationsLoadable.data.length}
              件の新しい通知
            </div>
          )}
        </section>
      </div>
    </div>
  );
}

高度な活用テクニック

エラーリトライ機能の実装

自動リトライ機能を組み込んだ atom の実装:

typescript// src/atoms/retryAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

interface RetryConfig {
  maxRetries: number;
  delay: number;
  backoff?: boolean;
}

// リトライカウントを管理するatom
const retryCountAtom = atom(0);

// リトライ設定atom
const retryConfigAtom = atom<RetryConfig>({
  maxRetries: 3,
  delay: 1000,
  backoff: true,
});

// リトライ可能なAPIコールatom
export const retryableApiAtom = atom(async (get) => {
  const retryCount = get(retryCountAtom);
  const config = get(retryConfigAtom);

  const makeRequest = async (
    attempt: number
  ): Promise<any> => {
    try {
      const response = await fetch(
        '/api/unreliable-endpoint'
      );

      if (!response.ok) {
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }

      return response.json();
    } catch (error) {
      if (attempt < config.maxRetries) {
        // バックオフ戦略(指数的に遅延を増加)
        const delay = config.backoff
          ? config.delay * Math.pow(2, attempt)
          : config.delay;

        console.log(
          `リトライ ${attempt + 1}/${
            config.maxRetries
          }${delay}ms 後に実行`
        );

        await new Promise((resolve) =>
          setTimeout(resolve, delay)
        );
        return makeRequest(attempt + 1);
      }

      throw new Error(
        `${config.maxRetries}回のリトライ後も失敗: ${error.message}`
      );
    }
  };

  return makeRequest(0);
});

export const retryableApiLoadableAtom = loadable(
  retryableApiAtom
);

// 手動リトライ用のaction atom
export const manualRetryAtom = atom(null, (get, set) => {
  const currentCount = get(retryCountAtom);
  set(retryCountAtom, currentCount + 1);
});

リトライ機能付きコンポーネント

typescript// src/components/RetryableComponent.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  retryableApiLoadableAtom,
  manualRetryAtom,
} from '../atoms/retryAtoms';

export function RetryableComponent() {
  const loadable = useAtomValue(retryableApiLoadableAtom);
  const retry = useSetAtom(manualRetryAtom);

  return (
    <div>
      {loadable.state === 'loading' && (
        <div>データを取得中...</div>
      )}

      {loadable.state === 'hasError' && (
        <div className='error-with-retry'>
          <p>エラー: {loadable.error.message}</p>
          <button onClick={() => retry()}>再試行</button>
        </div>
      )}

      {loadable.state === 'hasData' && (
        <div>成功: {JSON.stringify(loadable.data)}</div>
      )}
    </div>
  );
}

キャッシュ戦略との組み合わせ

loadable とキャッシュを組み合わせた実装:

typescript// src/atoms/cachedAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  expiry: number;
}

// インメモリキャッシュの実装
class MemoryCache<T> {
  private cache = new Map<string, CacheEntry<T>>();

  set(
    key: string,
    data: T,
    ttlMs: number = 5 * 60 * 1000
  ): void {
    const now = Date.now();
    this.cache.set(key, {
      data,
      timestamp: now,
      expiry: now + ttlMs,
    });
  }

  get(key: string): T | null {
    const entry = this.cache.get(key);

    if (!entry) {
      return null;
    }

    if (Date.now() > entry.expiry) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

  clear(): void {
    this.cache.clear();
  }
}

const cache = new MemoryCache();

// キャッシュキーを管理するatom
export const cacheKeyAtom = atom<string>('');

// キャッシュ機能付きのデータ取得atom
export const cachedDataAtom = atom(
  async (get): Promise<any> => {
    const cacheKey = get(cacheKeyAtom);

    if (!cacheKey) {
      throw new Error('Cache key is required');
    }

    // キャッシュから取得を試行
    const cachedData = cache.get(cacheKey);
    if (cachedData) {
      console.log(
        `キャッシュからデータを取得: ${cacheKey}`
      );
      return cachedData;
    }

    // キャッシュにない場合はAPIから取得
    console.log(`APIからデータを取得: ${cacheKey}`);
    const response = await fetch(`/api/data/${cacheKey}`);

    if (!response.ok) {
      throw new Error(
        `Failed to fetch data: ${response.statusText}`
      );
    }

    const data = await response.json();

    // キャッシュに保存(5分間有効)
    cache.set(cacheKey, data, 5 * 60 * 1000);

    return data;
  }
);

export const cachedDataLoadableAtom =
  loadable(cachedDataAtom);

// キャッシュクリア用のaction atom
export const clearCacheAtom = atom(null, () => {
  cache.clear();
  console.log('キャッシュをクリアしました');
});

カスタム loadable の作成

独自の loadable 実装を作成する方法:

typescript// src/utils/customLoadable.ts
import { atom } from 'jotai';
import type { Atom, Getter } from 'jotai';

interface CustomLoadableState<T> {
  state: 'idle' | 'loading' | 'success' | 'error';
  data?: T;
  error?: Error;
  progress?: number;
  lastUpdated?: number;
}

// プログレス付きのカスタムloadable
export function loadableWithProgress<T>(
  asyncAtom: Atom<Promise<T>>
): Atom<CustomLoadableState<T>> {
  return atom(
    async (
      get: Getter
    ): Promise<CustomLoadableState<T>> => {
      try {
        // プログレス状態を更新(実際の実装では外部から更新)
        const startTime = Date.now();

        // 非同期処理の実行
        const data = await get(asyncAtom);

        return {
          state: 'success',
          data,
          lastUpdated: Date.now(),
        };
      } catch (error) {
        return {
          state: 'error',
          error:
            error instanceof Error
              ? error
              : new Error(String(error)),
          lastUpdated: Date.now(),
        };
      }
    }
  );
}

// 使用例
const heavyCalculationAtom = atom(
  async (): Promise<number> => {
    // 重い計算処理のシミュレーション
    return new Promise((resolve) => {
      let progress = 0;
      const interval = setInterval(() => {
        progress += 10;
        console.log(`計算進捗: ${progress}%`);

        if (progress >= 100) {
          clearInterval(interval);
          resolve(42);
        }
      }, 100);
    });
  }
);

export const heavyCalculationLoadableAtom =
  loadableWithProgress(heavyCalculationAtom);

よくあるトラブルと解決策

メモリリーク対策

コンポーネントのアンマウント時のクリーンアップが重要です:

typescript// src/hooks/useCleanupAtom.ts
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { RESET } from 'jotai/utils';

// クリーンアップ用のカスタムフック
export function useCleanupAtom<T>(
  atom: WritableAtom<T, any, any>
) {
  const setAtom = useSetAtom(atom);

  useEffect(() => {
    return () => {
      // コンポーネントアンマウント時にatomをリセット
      setAtom(RESET);
    };
  }, [setAtom]);
}

// 実際のエラー例とその対策
/*
エラー例:
Warning: Can't perform a React state update on an unmounted component. 
This is a no-op, but it indicates a memory leak in your application.

原因: 
非同期処理完了後にコンポーネントがアンマウントされている

対策:
AbortControllerを使用した処理の中断
*/

// AbortController対応のatom
export const abortableApiAtom = atom(
  async (get, { signal }): Promise<any> => {
    const response = await fetch('/api/data', { signal });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  }
);

無限リロード問題

依存関係の循環により無限リロードが発生する問題の対策:

typescript// 問題のあるコード例
const badAtom = atom(async (get) => {
  const result = await fetch('/api/data');
  const data = await result.json();

  // 🚫 NGパターン: 自分自身を更新している
  set(someOtherAtom, data.timestamp);

  return data;
});

// 正しい実装
const timestampAtom = atom<number>(0);

const goodAtom = atom(async (get) => {
  const response = await fetch('/api/data');
  const data = await response.json();

  return data;
});

// 別のatomで副作用を処理
const sideEffectAtom = atom(null, (get, set) => {
  const loadable = get(loadable(goodAtom));

  if (loadable.state === 'hasData') {
    set(timestampAtom, loadable.data.timestamp);
  }
});

// 実際のエラー例
/*
Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

原因:
1. atom内でのstate更新
2. useEffect の依存配列の誤り
3. 循環参照

対策:
1. 副作用は別atomで処理
2. useMemo/useCallback の適切な使用
3. 依存関係の見直し
*/

TypeScript での型安全性

loadable の型安全性を高める実装:

typescript// src/types/loadable.ts
import type { Loadable } from 'jotai/utils';

// 型ガード関数の定義
export function isLoadableData<T>(
  loadable: Loadable<T>
): loadable is { state: 'hasData'; data: T } {
  return loadable.state === 'hasData';
}

export function isLoadableError<T>(
  loadable: Loadable<T>
): loadable is { state: 'hasError'; error: unknown } {
  return loadable.state === 'hasError';
}

export function isLoadableLoading<T>(
  loadable: Loadable<T>
): loadable is { state: 'loading' } {
  return loadable.state === 'loading';
}

// エラーハンドリングのユーティリティ
export function getLoadableError(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  if (typeof error === 'string') {
    return error;
  }

  return '不明なエラーが発生しました';
}

// 使用例
function TypeSafeComponent() {
  const loadable = useAtomValue(someLoadableAtom);

  if (isLoadableLoading(loadable)) {
    return <div>Loading...</div>;
  }

  if (isLoadableError(loadable)) {
    return (
      <div>Error: {getLoadableError(loadable.error)}</div>
    );
  }

  if (isLoadableData(loadable)) {
    // TypeScript が loadable.data の型を正しく推論
    return <div>{loadable.data.someProperty}</div>;
  }

  // この行は到達不可能
  return null;
}

// 実際のTypeScriptエラー例と対策
/*
TypeScriptエラー例:
TS2339: Property 'data' does not exist on type 'Loadable<User>'.

原因:
型ガードを使わずにdataにアクセスしている

対策:
適切な型ガード関数を使用する
*/

パフォーマンス最適化

不要な再レンダリングの防止

React.memo と loadable の組み合わせ:

typescript// src/components/OptimizedComponent.tsx
import React from 'react';
import { useAtomValue } from 'jotai';
import { isLoadableData } from '../types/loadable';

interface Props {
  userId: string;
}

// メモ化されたコンポーネント
const UserCard = React.memo(({ userId }: Props) => {
  const userLoadable = useAtomValue(userLoadableAtom);

  if (!isLoadableData(userLoadable)) {
    return <div>Loading user...</div>;
  }

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

// 条件付きレンダリングの最適化
const ConditionalRenderer = React.memo(() => {
  const loadable = useAtomValue(someLoadableAtom);

  // 早期リターンパターン
  if (loadable.state === 'loading') {
    return <LoadingSpinner />;
  }

  if (loadable.state === 'hasError') {
    return <ErrorDisplay error={loadable.error} />;
  }

  // データがある場合のみ重いコンポーネントをレンダリング
  return <HeavyDataComponent data={loadable.data} />;
});

選択的データ取得

必要なデータのみを取得する最適化:

typescript// src/atoms/selectiveAtoms.ts
import { atom } from 'jotai';
import { loadable, selectAtom } from 'jotai/utils';

interface FullUserData {
  id: string;
  name: string;
  email: string;
  profile: {
    bio: string;
    avatar: string;
    settings: {
      theme: string;
      notifications: boolean;
      privacy: object;
    };
  };
  posts: Post[];
  friends: User[];
}

// フルデータを取得するatom
const fullUserAtom = atom(
  async (): Promise<FullUserData> => {
    const response = await fetch('/api/user/full');
    return response.json();
  }
);

// 必要な部分のみを選択するatom群
export const userBasicInfoAtom = selectAtom(
  fullUserAtom,
  (user) => ({
    id: user.id,
    name: user.name,
    email: user.email,
  })
);

export const userSettingsAtom = selectAtom(
  fullUserAtom,
  (user) => user.profile.settings
);

export const userPostsCountAtom = selectAtom(
  fullUserAtom,
  (user) => user.posts.length
);

// 各部分をloadableでラップ
export const userBasicLoadableAtom = loadable(
  userBasicInfoAtom
);
export const userSettingsLoadableAtom = loadable(
  userSettingsAtom
);
export const userPostsCountLoadableAtom = loadable(
  userPostsCountAtom
);

バックグラウンド更新

ユーザーが気づかないうちにデータを更新する実装:

typescript// src/atoms/backgroundUpdateAtoms.ts
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';

// 最後の更新時刻を管理
const lastUpdateAtom = atom<number>(0);

// バックグラウンド更新の間隔(5分)
const UPDATE_INTERVAL = 5 * 60 * 1000;

// バックグラウンド更新機能付きのatom
export const autoUpdatingDataAtom = atom(
  async (get): Promise<any> => {
    const lastUpdate = get(lastUpdateAtom);
    const now = Date.now();

    // 前回の更新から十分時間が経過している場合のみAPI呼び出し
    const shouldUpdate = now - lastUpdate > UPDATE_INTERVAL;

    if (shouldUpdate) {
      const response = await fetch('/api/live-data');
      const data = await response.json();

      // 更新時刻を記録(副作用なので別atomで実装すべき)
      return {
        ...data,
        lastFetched: now,
      };
    }

    // キャッシュされたデータを返す(実際の実装ではキャッシュから取得)
    throw new Error('Using cached data');
  }
);

export const autoUpdatingLoadableAtom = loadable(
  autoUpdatingDataAtom
);

// ページvisibility APIを使った賢い更新
export const visibilityAwareAtom = atom(
  async (get): Promise<any> => {
    // ページが非表示の場合は更新しない
    if (document.hidden) {
      throw new Error('Page is hidden, skipping update');
    }

    const response = await fetch('/api/data');
    return response.json();
  }
);

// 使用例:定期的な更新
export const useBackgroundUpdate = () => {
  const setLastUpdate = useSetAtom(lastUpdateAtom);

  useEffect(() => {
    const interval = setInterval(() => {
      if (!document.hidden) {
        setLastUpdate(Date.now());
      }
    }, UPDATE_INTERVAL);

    return () => clearInterval(interval);
  }, [setLastUpdate]);
};

まとめ

Jotai のloadableユーティリティは、React アプリケーションにおける非同期状態管理を革新的に改善します。従来のアプローチと比較して、以下のような大きなメリットを提供します。

loadable の主要な利点

項目従来の方法loadable
状態管理手動で複数の状態を同期自動的に統一された状態管理
型安全性型チェックが困難TypeScript 完全対応
エラーハンドリング分散したエラー処理一元化されたエラー管理
コード量冗長な記述が必要簡潔で読みやすい
競合状態手動で制御が必要自動的に最新状態を保持

実装のベストプラクティス

本記事で紹介した実装パターンを活用することで、以下のような高品質なアプリケーションを構築できます:

  1. 型安全なエラーハンドリング: TypeScript の型システムを活用した安全なコード
  2. 効率的なリソース管理: メモリリークや無限リロードを防ぐクリーンな実装
  3. 優れたユーザー体験: 適切なローディング表示とエラー状態の管理
  4. スケーラブルな設計: 大規模なアプリケーションにも対応できる柔軟性

今後の発展性

loadable は単なる状態管理ツールを超えて、React アプリケーションのアーキテクチャ全体を改善する強力な基盤となります。GraphQL、React Query、Next.js などの他の技術と組み合わせることで、さらに高度なアプリケーションを構築できるでしょう。

現代の Web アプリケーション開発において、非同期処理の適切な管理は必須のスキルです。loadable をマスターすることで、より保守性が高く、ユーザビリティに優れたアプリケーションを開発できるようになります。ぜひ実際のプロジェクトで活用してみてください。

関連リンク