JavaScript Promise 実践ガイド|実務で使える非同期処理パターン集

JavaScript

JavaScript Promise 実践ガイド|実務で使える非同期処理パターン集

JavaScriptの非同期処理は、モダンなWeb開発において避けられないスキルです。その中でもPromiseは、コールバック地獄を脱却し、読みやすく保守性の高い非同期処理を実装するための基本となります。本記事では、単なる理論ではなく、実務プロジェクトで実際に役立つPromiseの使い方を紹介します。

簡易的な解説:Promiseとは何か

Promiseは、非同期操作の完了(または失敗)を表すオブジェクトです。3つの状態を持ちます:

  • pending(保留中):操作がまだ完了していない状態
  • fulfilled(成功):操作が正常に完了した状態
  • rejected(失敗):操作がエラーで終了した状態

Promiseの基本的な使い方は以下のとおりです:

const promise = new Promise((resolve, reject) => {
  // 非同期処理
  if (条件) {
    resolve(結果); // 成功時
  } else {
    reject(エラー); // 失敗時
  }
});

promise
  .then(result => console.log(result))
  .catch(error => console.error(error));

実務では、ほとんどの場合、既存のPromiseベースのAPI(fetch、axios等)を使うため、Promiseの作成自体は少なくなります。むしろ、それらを組み合わせ、エラーハンドリング、並列処理を効率的に行うことが重要です。

業務でのユースケース

以下は、実務プロジェクトで頻繁に出会うシナリオです:

ユースケース1:複数のAPI呼び出しの順序制御

ユーザー情報を取得した後、その情報を使って関連データを取得するような場合です。単純に順番に呼び出すだけでなく、エラー時の処理やタイムアウトの考慮が必要です。

ユースケース2:並列処理による高速化

複数のAPI呼び出しが独立している場合、並列実行することでレスポンス時間を短縮できます。

ユースケース3:条件付き非同期処理フロー

特定の条件に応じて、異なるAPI呼び出しを実行するような複雑なフローです。

実装コード:実務で使えるサンプル

実装例1:ユーザー情報取得 → 注文履歴取得

最も一般的なシーケンシャルなAPI呼び出しです。最初のリクエストの結果を使って、次のリクエストを実行します。

// TypeScript版
interface User {
  id: number;
  name: string;
  email: string;
}

interface Order {
  orderId: number;
  userId: number;
  total: number;
  date: string;
}

// ユーザー情報を取得
function fetchUser(userId: number): Promise {
  return fetch(`/api/users/${userId}`)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    });
}

// 注文履歴を取得
function fetchOrders(userId: number): Promise {
  return fetch(`/api/orders?userId=${userId}`)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    });
}

// チェーンして処理
function getUserWithOrders(userId: number): Promise<{ user: User; orders: Order[] }> {
  return fetchUser(userId)
    .then(user => {
      return fetchOrders(user.id)
        .then(orders => ({
          user,
          orders
        }));
    })
    .catch(error => {
      console.error('ユーザー情報または注文履歴の取得に失敗:', error);
      throw error;
    });
}

// 使用例
getUserWithOrders(123)
  .then(data => {
    console.log('ユーザー:', data.user);
    console.log('注文:', data.orders);
  })
  .catch(error => {
    console.error('エラー:', error);
  });

実装例2:複数のAPI呼び出しを並列実行

ユーザー情報、注文履歴、推奨商品を同時に取得する場合です。Promise.all()を使うことで、すべてが完了するまで待ちます。

interface RecommendedProduct {
  productId: number;
  name: string;
  price: number;
}

function fetchRecommendations(userId: number): Promise {
  return fetch(`/api/recommendations?userId=${userId}`)
    .then(response => response.json());
}

// 並列実行版
function getUserDataInParallel(userId: number) {
  return Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchRecommendations(userId)
  ])
    .then(([user, orders, recommendations]) => ({
      user,
      orders,
      recommendations
    }))
    .catch(error => {
      console.error('データ取得に失敗:', error);
      throw error;
    });
}

// 使用例
getUserDataInParallel(123)
  .then(data => {
    console.log('全データ取得完了');
    console.log(data);
  });

実装例3:タイムアウト処理を含む実装

実務では、API呼び出しがハングしないようにタイムアウト処理が必須です。

function fetchWithTimeout(
  url: string,
  timeout: number = 5000
): Promise {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  return fetch(url, { signal: controller.signal })
    .then(response => {
      clearTimeout(timeoutId);
      return response;
    })
    .catch(error => {
      clearTimeout(timeoutId);
      if (error.name === 'AbortError') {
        throw new Error(`リクエストがタイムアウトしました(${timeout}ms)`);
      }
      throw error;
    });
}

// 使用例
function getUserWithTimeout(userId: number): Promise {
  return fetchWithTimeout(`/api/users/${userId}`, 3000)
    .then(response => response.json());
}

getUserWithTimeout(123)
  .then(user => console.log(user))
  .catch(error => console.error('エラー:', error.message));

実装例4:リトライロジック

ネットワークエラーなど一時的な失敗に対応するリトライ機能は、実務では非常に重要です。

function fetchWithRetry(
  url: string,
  maxRetries: number = 3,
  delay: number = 1000
): Promise {
  let attempt = 0;

  function attempt_fetch(): Promise {
    attempt++;
    return fetch(url)
      .then(response => {
        if (response.ok) {
          return response;
        }
        // 5xxエラーはリトライ対象
        if (response.status >= 500 && attempt < maxRetries) {
          return new Promise(resolve => {
            setTimeout(() => resolve(attempt_fetch()), delay * attempt);
          });
        }
        throw new Error(`HTTP ${response.status}`);
      })
      .catch(error => {
        // ネットワークエラー時はリトライ
        if (attempt < maxRetries && (error instanceof TypeError)) {
          console.log(`リトライ ${attempt}/${maxRetries}: ${url}`);
          return new Promise(resolve => {
            setTimeout(() => resolve(attempt_fetch()), delay * attempt);
          });
        }
        throw error;
      });
  }

  return attempt_fetch();
}

// 使用例
fetchWithRetry('/api/users/123', 3, 1000)
  .then(response => response.json())
  .then(user => console.log(user))
  .catch(error => console.error('最終的に失敗:', error));

実装例5:Promise.allSettled() で部分的な失敗を許容

複数のAPI呼び出しで、いくつかが失敗してもすべての結果を取得したい場合です。

function processUserDataRobustly(userIds: number[]) {
  const promises = userIds.map(id =>
    fetchUser(id)
      .then(user => ({ status: 'success', data: user }))
      .catch(error => ({ status: 'error', error }))
  );

  // すべてのPromiseが解決するまで待つ(失敗も含む)
  return Promise.allSettled(promises)
    .then(results => {
      const successful = results
        .filter(r => r.status === 'fulfilled')
        .map(r => (r as PromiseFulfilledResult).value);

      const failed = results
        .filter(r => r.status === 'rejected')
        .map(r => (r as PromiseRejectedResult).reason);

      console.log(`成功: ${successful.length}件, 失敗: ${failed.length}件`);
      return { successful, failed };
    });
}

// 使用例
processUserDataRobustly([1, 2, 3, 4, 5])
  .then(result => {
    console.log('処理完了:', result);
  });

よくある応用パターン

パターン1:async/await を使った可読性の向上

モダンなJavaScriptでは、async/awaitを使うことが一般的です。Promiseチェーンより読みやすくなります。

async function getUserDataModern(userId: number) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const recommendations = await fetchRecommendations(user.id);

    return { user, orders, recommendations };
  } catch (error) {
    console.error('データ取得に失敗:', error);
    throw error;
  }
}

// 並列実行版(async/await)
async function getUserDataInParallelModern(userId: number) {
  try {
    const [user, orders, recommendations] = await Promise.all([
      fetchUser(userId),
      fetchOrders(userId),
      fetchRecommendations(userId)
    ]);

    return { user, orders, recommendations };
  } catch (error) {
    console.error('データ取得に失敗:', error);
    throw error;
  }
}

// 使用例
const data = await getUserDataModern(123);
console.log(data);

パターン2:Promise.race() で最初に完了したものを取得

複数のデータソースから最初に応答したものを使用したい場合に便利です。

function fetchUserFromFastestSource(userId: number): Promise {
  const primarySource = fetchWithTimeout(
    `https://primary-api.example.com/users/${userId}`,
    2000
  );
  const fallbackSource = fetchWithTimeout(
    `https://backup-api.example.com/users/${userId}`,
    2000
  );

  return Promise.race([primarySource, fallbackSource])
    .then(response => response.json())
    .catch(error => {
      console.error('すべてのソースから取得失敗:', error);
      throw error;
    });
}

// 使用例
fetchUserFromFastestSource(123)
  .then(user => console.log('取得したユーザー:', user));

パターン3:Promise チェーンのキャンセル処理

ユーザーが操作をキャンセルしたときに、進行中のPromiseを停止する必要があります。

class CancellablePromiseChain {
  private controller: AbortController;

  constructor() {
    this.controller = new AbortController();
  }

  async fetchUserData(userId: number) {
    try {
      const signal = this.controller.signal;

      const response = await fetch(`/api/users/${userId}`, { signal });
      if (!response.ok) throw new Error('Failed to fetch');

      const data = await response.json();
      return data;
    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        console.log('リクエストがキャンセルされました');
      }
      throw error;
    }
  }

  cancel() {
    this.controller.abort();
  }
}

// 使用例
const chain = new CancellablePromiseChain();
const promise = chain.fetchUserData(123);

// 2秒後にキャンセル
setTimeout(() => chain.cancel(), 2000);

promise
  .then(data => console.log(data))
  .catch(error => console.error(error));

注意点:実務で気を付けるべきこと

1. 必ずエラーハンドリングを実装する

Promiseチェーンの末尾に.catch()を忘れないことが重要です。エラーハンドリングなしでは、予期しないエラーがサイレントに失敗します。

// ❌ 悪い例:エラーハンドリングなし
fetchUser(123)
  .then(user => console.log(user));
  // .catch()がない!

// ✅ 良い例:エラーハンドリングあり
fetchUser(123)
  .then(user => console.log(user))
  .catch(error => {
    console.error('エラー:', error);
    // ログ出力、ユーザーへの通知など
  });

2. Promise.all() でのエラー処理に注意

Promise.all()は、1つのPromiseが失敗するとすぐに失敗します。部分的な失敗を許容したい場合はPromise.allSettled()を使用してください。

// ❌ 1つが失敗するとすべて失敗
Promise.all([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3) // これが失敗するとすべて失敗
])
  .catch(error => console.error(error));

// ✅ 部分的な失敗を許容
Promise.allSettled([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3) // これが失敗してもほかは続行
])
  .then(results => {
    // すべての結果を処理
  });

3. メモリリークを防ぐ

コンポーネントがアンマウントされるときにPromiseをキャンセルする必要があります。特にReactなどのフレームワークでは重要です。

// React の例
import { useEffect, useRef } from 'react';

function UserComponent({ userId }: { userId: number }) {
  const abortControllerRef = useRef(null);

  useEffect(() => {
    abortControllerRef.current = new AbortController();

    fetch(`/api/users/${userId}`, {
      signal: abortControllerRef.current.signal
    })
      .then(response => response.json())
      .then(user => console.log(user))
      .catch(error => {
        if (error.name !== 'AbortError') {
          console.error('エラー:', error);
        }
      });

    return () => {
      // クリーンアップ:コンポーネントアンマウント時にキャンセル
      abortControllerRef.current?.abort();
    };
  }, [userId]);

  return 
ユーザー情報
; }

4. タイムアウトの設定は必須

タイムアウトなしのPromiseは、無制限に待機し続ける可能性があります。

// ❌ タイムアウトなし(危険)
fetch('/api/users/123')
  .then(response => response.json());

// ✅ タイムアウトあり(推奨)
fetchWithTimeout('/api/users/123', 5000)
  .then(response => response.json())
  .catch(error => console.error(error));

5. 不要なPromiseチェーンを避ける

Promiseチェーンが深くなりすぎると、可読性が低下します。async/awaitを使用することをお勧めします。

// ❌ ネストが深い
fetchUser(123)
  .then(user =>
    fetchOrders(user.id)
      .then(orders =>
        fetchRecommendations(user.id)
          .then(recommendations => ({
            user,
            orders,
            recommendations
          }))
      )
  );

// ✅ async/await は読みやすい
async function getData(userId: number) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  const recommendations = await fetchRecommendations(user.id);
  return { user, orders, recommendations };
}

まとめ

JavaScriptのPromise

  • 順序制御:チェーンやasync/awaitで前のAPIの結果を次のAPIで使用
  • 並列処理:Promise.all()で独立したAPI呼び出しを同時実行
  • エラーハンドリング:必ず.catch()を実装し、タイムアウトも設定
  • リトライロジック:一時的なエラーに対応できる仕組みを用意
  • キャンセル処理:AbortControllerを使用してメモリリークを防ぐ
  • 可読性:複雑な処理はasync/awaitで記述

また、モダンなJavaScriptではasync/awaitを優先的に使うことで、コードの可読性と保守性が大幅に向上します。Promiseの動作を理解したうえで、適切なパターンを選択することが、堅牢で効率的なアプリケーション開発につながります。

実務では、単にPromiseを使うだけでなく、エラーシナリオやエッジケースを想定した設計が求められます。本記事で紹介したサンプルコードを参考に、プロジェクトに合わせてカスタマイズすることで、より堅牢なコードが実現できるでしょう。

タイトルとURLをコピーしました