JavaScript | 非同期処理:Promise 応用 – Promise の落とし穴

JavaScript JavaScript
スポンサーリンク

まず「Promise の落とし穴」をざっくり整理する

Promise 自体は仕組みとしてはそんなに難しくないのに、
実際に書き始めると「なんか思った通りに動かない」「エラーが消える」「順番がおかしい」みたいな、
モヤっとする挙動にハマりがちです。

そのほとんどは、

thencatch が何を返しているかを意識していない」
「Promise の“非同期性”を、同期の感覚で扱ってしまう」

ここから来ます。

ここが重要です。
Promise の落とし穴は、“言語仕様の罠” というより、
「値・エラー・タイミングの流れを、自分の頭でちゃんと追えているか」 のところでほぼ決まります。
その視点で、一つずつ噛み砕いていきます。


落とし穴1:then の中で return を忘れてチェーンが切れる

何が起きるか

典型的なのがこれです。

fetchUser()
  .then((user) => {
    console.log("user:", user);
    fetchPosts(user.id);    // ここで return してない
  })
  .then((posts) => {
    console.log("posts:", posts);  // posts が undefined だったり、タイミングがズレたりする
  })
  .catch((err) => {
    console.error("エラー:", err);
  });
JavaScript

「ユーザーを取ってから投稿を取るつもり」が、
なぜか postsundefined になったり、
fetchPosts の結果がこのチェーンに乗らなかったりします。

理由はシンプルで、
「1 つ目の then の戻り値が undefined になっているから」 です。

正しい動きと比較してみる

正しくはこうです。

fetchUser()
  .then((user) => {
    console.log("user:", user);
    return fetchPosts(user.id);   // Promise を return する
  })
  .then((posts) => {
    console.log("posts:", posts); // ここに fetchPosts の結果が来る
  });
JavaScript

Promise のルールでは、

then の中で return したものが
→ 次の then の入力になる

です。

「return を書き忘れる」=「暗黙に undefined を返す」
→ 次の then は「undefined で成功した Promise」として動き出す
→ 期待していた Promise チェーンは切れてしまう

という流れになります。

ここが重要です。
「then の最後は return で終わる」が基本ルール。
特に次の非同期処理(Promise)を呼ぶときは、必ず return xxxPromise(...) と書く癖をつけると、安全度が一気に上がります。


落とし穴2:非同期の結果を「外の変数に代入してすぐ使おうとする」

よくあるコード

これも初心者が必ず一度はやるやつです。

let data;

fetchSomething().then((result) => {
  data = result;
});

console.log(data);  // まだ undefined
JavaScript

「あとで data を使いたいから外に出しておこう」という発想ですが、
Promise は「非同期」なので、console.log が走る時点では、
まだ then が実行されていない可能性が高いです。

結果として「なぜか常に undefined」という状態になります。

なぜそうなるか(タイミングの問題)

Promise をざっくり時間軸で見ると、

  1. fetchSomething() を呼ぶ(非同期処理スタート)
  2. すぐに then の登録だけされる(実行はあと)
  3. 同期コードがそのまま進み、console.log(data) が走る(この時点ではまだ data は代入されていない)
  4. 非同期処理が終わったタイミングで、then の中が走る

という順番になっています。

Promise の世界では、

「外に変数を置いて、後でそこに結果をセット」という書き方をしようとするほど、タイミングの罠にハマる
と思っておいた方がいいです。

どう書き換えるべきか

「結果を使いたい処理」は、
Promise の中に“ぶらさげる” のが基本です。

fetchSomething()
  .then((data) => {
    console.log("ここで data を使う:", data);
  })
  .catch((err) => {
    console.error("エラー:", err);
  });
JavaScript

あるいは、関数として次につなぐ。

function handleData(data) {
  console.log("ここで data を使う:", data);
}

fetchSomething().then(handleData);
JavaScript

ここが重要です。
Promise の結果を「外に持ち出してあとで同期的に使う」のではなく、「結果を使う処理ごと then / async 関数の中に入れる」。
非同期の世界の中で完結させるのが、タイミングの落とし穴を避ける一番のコツです。


落とし穴3:エラーを catch しない(UnhandledPromiseRejection)

何が問題になるか

Promise の中でエラーが起きても、
どこにも catch がないと、環境によっては

「UnhandledPromiseRejectionWarning」
「未処理の Promise 拒否」

のような警告が出ます。
最近のブラウザや Node では、ときにこれは致命的とみなされることもあります。

例:

new Promise((resolve, reject) => {
  reject(new Error("何かがおかしい"));
});

// catch なし
JavaScript

どこか一箇所でいいから必ず catch をつける

Promise チェーンのどこかで失敗する可能性があるなら、
一番最後に catch を 1 個つけておく のがおすすめです。

doStep1()
  .then(doStep2)
  .then(doStep3)
  .catch((err) => {
    console.error("どこかで失敗:", err);
  });
JavaScript

doStepX のどこで rejectthrow があっても、
最後の catch が全部まとめて拾ってくれます。

ここが重要です。
「成功の流れ(then)はよく書くのに、エラーの出口(catch)を忘れがち」なのが落とし穴。
「Promise チェーンには必ず 1 箇所以上 catch をつける」を習慣化すると、意味不明な未処理エラーから解放されます。


落とし穴4:then の第2引数でエラーを拾ってしまって、後ろに伝わらない

こういう書き方

Promise は、then に第2引数を渡せます。

promise.then(
  (value) => { /* 成功時 */ },
  (error) => { /* 失敗時 */ }
);
JavaScript

しかし、これを多用すると、
エラー伝播が分かりづらくなります。

doSomething()
  .then(
    (value) => {
      console.log("成功:", value);
    },
    (error) => {
      console.error("ここでエラー処理:", error);
      // ここで throw しない場合、エラーは「処理された」とみなされる
    }
  )
  .then(() => {
    console.log("ここは普通に実行されてしまうこともある");
  })
  .catch((err) => {
    console.error("最後の catch:", err);
  });
JavaScript

何が起きているか

第2引数のエラーハンドラ(onRejected)でエラーを処理すると、
そこで「エラーが消化された」と解釈されます。

そのとき何も throw しなければ、
次の then は「成功」として動き始めます。

結果として、

「エラーが起きたのに、なぜか後ろの then が普通に動いている」

という混乱が起きます。

解決策:エラーは catch に任せる

実務的なおすすめは、

then には成功時だけを書く
エラーは最後の catch(または途中の catch)でまとめて扱う

というパターンに統一することです。

doSomething()
  .then((value) => {
    console.log("成功:", value);
  })
  .then(() => {
    console.log("さらに続き");
  })
  .catch((err) => {
    console.error("どこかで失敗:", err);
  });
JavaScript

ここが重要です。
「成功は then」「失敗は catch」という役割分担にしておくと、
エラーがどこで止まるか分からない、という Promise 特有の混乱をかなり減らせます。


落とし穴5:forEach と async/await(や Promise)を組み合わせて「逐次処理」のつもりになる

よくある誤解

例えば、「配列の要素を順番に非同期処理したい」とします。

こう書いてしまうことがあります。

const urls = ["/a", "/b", "/c"];

urls.forEach((url) => {
  fetch(url)
    .then((res) => res.text())
    .then((text) => console.log(url, "完了"));
});

console.log("全部終わり");
JavaScript

あるいは async/await でも:

urls.forEach(async (url) => {
  const res = await fetch(url);
  const text = await res.text();
  console.log(url, "完了");
});

console.log("全部終わり");
JavaScript

「forEach で順番にやってるつもり」
でも、実際には全部同時にスタートしていて、
console.log("全部終わり") のほうが先に出ます。

なぜか

forEach 自体は同期的に、内側のコールバックを次々呼びます。
その中で fetch などの非同期を呼んでも、forEach は「その完了を待たない」のがポイントです。

async/await になっても、

urls.forEach(async (url) => {
  await fetch(url);
});
JavaScript

という書き方では、「外側の forEach は待っていない」ので、
「全部終わってから次へ進む」保証はありません。

正しい逐次処理/並列処理の書き方

逐次処理にしたいなら、for 文や reduce+Promise チェーンを使うべきです。

async function main() {
  for (const url of urls) {
    const res = await fetch(url);
    const text = await res.text();
    console.log(url, "完了");
  }
  console.log("全部順番に終わり");
}
JavaScript

Promise チェーンなら:

urls
  .reduce((prev, url) => {
    return prev.then(() => {
      return fetch(url).then((res) => res.text());
    });
  }, Promise.resolve())
  .then(() => {
    console.log("全部順番に終わり");
  });
JavaScript

ここが重要です。
「forEach の中で async/await(や Promise)を書いても、“forEach 自体” は待たない」。
配列を非同期で扱うときは、「逐次にしたいのか」「並列にしたいのか」を意識して、構造(for / Promise.all など)を選ぶ必要があります。


落とし穴6:Promise コンストラクタの過剰使用(無意味な二重ラップ)

ありがちなアンチパターン

fetch や既に Promise を返す関数に対して、
こんなラップを書いてしまうことがあります。

function getData() {
  return new Promise((resolve, reject) => {
    fetch("/api/data")
      .then((res) => res.json())
      .then((data) => resolve(data))
      .catch((err) => reject(err));
  });
}
JavaScript

一見よさそうですが、これは

「Promise を返す fetch を、同じ意味の Promise で包み直しているだけ」

になっていて、全く意味がありません。

どう書くべきか

この場合、resolve / reject で中継せず、
そのままチェーンを返せばよいです。

function getData() {
  return fetch("/api/data").then((res) => res.json());
}
JavaScript

あるいは async/await なら:

async function getData() {
  const res = await fetch("/api/data");
  return res.json();
}
JavaScript

new Promise を使う必要があるのは、
「既存のAPIがコールバックベース」だったり、
「タイムアウトやリトライなど、独自の制御をしたいとき」です。

ここが重要です。
「元から Promise なもの」を、意味もなく new Promise で包み直すのはアンチパターン。
“Promise を返さないもの” を Promise 化するときにだけ、new Promise を使うのが基本です。


落とし穴7:Promise の「非同期性」を忘れて順序を勘違いする

ログの順序で混乱するパターン

こんなコードを考えます。

console.log("A");

Promise.resolve()
  .then(() => {
    console.log("B");
  });

console.log("C");
JavaScript

直感的には「A → B → C」と出そうですが、
実際の出力は「A → C → B」です。

理由は、

Promise の then は、「今の同期処理が全部終わったあと」に実行される
(マイクロタスクとしてイベントループに登録される)

からです。

最初に this を理解していないと、 「なんで B が最後なの?」という謎現象に見えます。

実務での影響

Promise の中の処理は、
「必ず少なくとも 1 tick 後に実行される」 と考えたほうが安全です。

「then の中でフラグを true にして、すぐ後ろの同期コードでそれを頼りに何かする」
みたいな書き方をすると、タイミングがズレてバグになります。

ここが重要です。
「then の中身は、“今の処理の直後”ではなく、“今の処理が一段落してから”実行される」。
Promise の処理はいつも「少し後で動く」と頭に置いて、同期処理と混ぜて考えすぎないこと。


まとめ:Promise の落とし穴を避けるための “思考のクセ”

ここまでの落とし穴は、どれも根っこは同じです。

「今この then は何を return しているか?」
「その値(またはエラー)は、次のどこに渡っているか?」
「この処理はいつ(同期/非同期のどのタイミングで)実行されるか?」

これを意識して追っていないと、「なんとなく書いた Promise」が不思議な挙動をし始めます。

逆に言えば、
ここが重要です。
“then の return” と “catch でのエラーの流れ” と “非同期のタイミング” を丁寧に意識できるようになれば、Promise の落とし穴のほとんどは自然と避けられるようになります。

おすすめの練習は、次のようなことです。

小さな Promise チェーンを書いて、
各 then / catch の先頭で console.log("ここ", 値) を入れてみる
「どの順番でどんな値が来ているか」「どこでエラーが catch に飛ぶか」を、自分の目で追ってみる

これを何パターンかやると、
Promise の中で「値とエラーがどう流れているか」「いつ実行されているか」が感覚としてつかめてきて、
落とし穴にハマる回数が確実に減っていきます。

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