async にした onClick の例外は、React にも Error Boundary にも届かない
preventDefault はマイクロタスクなら間に合い、タスクをまたぐと間に合わない
ありふれたフォーム画面を思い浮かべてください。確認ダイアログ付きの送信ボタンと、同意チェックボックスが1つ載っています。どちらもよく見かける部品です。この記事では、同じ画面にあるこの2つの部品だけを使って検証します。
まずは送信ボタンです。押すと確認ダイアログが出る。キャンセルする。それだけのはずなのに、以降そのボタンは二度と反応しなくなる。
エラーメッセージらしきものは画面のどこにも出ない。開発者ツールのコンソールを開いて初めて、赤い1行を見つける。
こういう不具合を見たことがある人は多いはずです。なぜこんなことが起きるのでしょうか。原因を1つずつ辿ると、onClick を async にした瞬間から話が始まっていることが分かります。
目次
async にした onClick の戻り値は、誰も受け取らない
<button onClick={handleSubmit}> のように渡す関数を async にすると、その関数はもう void を返しません。呼び出すたびに Promise を返します。ここまでは知っている人が多いでしょう。
問題は、その Promise を誰も受け取っていないことです。DOM のイベントディスパッチ(イベントが発生してから、登録されたリスナーがすべて呼ばれ、既定の動作(ブラウザが用意している標準の反応)を実行するかどうかが決まるまでの、一続きの同期処理のことです)は、リスナーが返した値を待ちません。React も同様で、onClick の戻り値を await したりはしません。つまり handleSubmit の中で起きた例外は、呼び出し元へ「戻る」経路そのものが存在しないのです。
普通の同期関数なら、例外は呼び出し元へ伝播し、React ならレンダー中の例外を Error Boundary が拾ってくれます。ところが async 関数の中の例外は、拒否された Promise という形に変わります。受け取り手のいない Promise が拒否されると、それは JavaScript エンジンにとって「誰も対処しなかった失敗」として扱われます。
React の公式ドキュメントは、Error Boundary が捕まえる対象をはっきり限定しています。イベントハンドラの中の例外と、非同期コードの中の例外は、対象外だと明記されているのです(参考文献1)。async な onClick は、この2つの除外項目を両方とも兼ねています。
例外の行き先は unhandledrejection だけ
では、受け取り手のいない拒否された Promise は、どこへ行くのでしょうか。答えは unhandledrejection イベントです。MDN は、Promise が拒否され、かつその拒否を処理するハンドラが1つも付いていないときにこのイベントが発火する、と説明しています(参考文献2)。
図 1 は、この2つの経路の違いを示しています。同期のイベントハンドラなら例外は上に投げ返され、React のレンダー内なら Error Boundary が受け止めます。async なイベントハンドラの例外は、その両方を素通りして unhandledrejection へ抜けるのです。
厄介なのは、握り潰されているわけではないという点です。ブラウザは仕様どおりに unhandledrejection を発火させていて、実際に検証してみると、開発者ツールのコンソールには次の1行だけが出ます。
[error] Uncaught (in promise)
たった1行のこの表示が、「ボタンを押しても何も起きない」という症状とすぐには結びつきません。ここに、この種の不具合が見つかりにくい理由があります。
復旧は catch ではなく finally が担う
ここで実際に手を動かして確かめた結果を見てみましょう。確認ダイアログを模したヘルパー askConfirm() が、try ブロックへ入る前に throw する形のコードを2種類用意しました。復旧処理の置き場所だけが違います。
- A1: 送信中フラグを戻す処理を
catchに書く - A2: 同じ処理を
finallyに書く
askConfirm() の失敗は try の外で起きるので、catch には引っかかりません。A1 を1回押し、続けてもう1回押すと、こう記録されました。
[unhandledrejection] reason=Error: confirm helper failed
A1 ignored: already submitting
1回目のクリックで例外が catch を素通りし、送信中フラグが true のまま残ります。2回目のクリックは先頭のガード処理に弾かれ、ボタンは無反応になりました。まさに冒頭の症状です。
一方 A2 は、finally が例外の種類に関わらず必ず実行されるので、2回とも普通に押せました。
A2 catch: confirm helper failed
A2 catch: confirm helper failed
つまり「エラーを利用者に知らせること」と「状態を元へ戻すこと」は、別々に設計しないといけない関心事なのです。catch は前者の一部を担いますが、後者を保証してくれるのは finally だけです。どの経路で失敗しても状態を戻したいなら、その処理は最初から finally に置くべきだと言えます。
preventDefault はいつまでなら間に合うのか
もう1つ、async なハンドラでよく踏む落とし穴があります。preventDefault() を呼ぶタイミングです。
同じ async ハンドラの問題を、もう一段違う角度から見てみます。今度は既定の動作が目に見えるものが要るので、同じ画面にある同意チェックボックスで確かめます。チェックボックスの click には、チェック状態をトグルするという分かりやすい「既定の動作」があるので、それが止まったかどうかが一目で分かるからです。
この既定の動作を止められるかどうかを、preventDefault() を呼ぶ位置を変えた3つのハンドラで確かめました。
- B1: 同期でそのまま呼ぶ
- B2:
await Promise.resolve()の後に呼ぶ - B3:
await sleep(0)(setTimeout経由)の後に呼ぶ
正直に書くと、検証を始める前は「await を1回でもまたいだら preventDefault() は間に合わない」と予想していました。ところが実測の結果は違いました。
| ハンドラ | ディスパッチ完了後の checked | 既定の動作 |
|---|---|---|
| B1(同期) | false | 止まった |
B2(await Promise.resolve()後) | false | 止まった |
B3(await sleep(0)後) | true | 止まらなかった |
B2 は await をまたいでいるのに間に合っています。境界線は「await をまたいだかどうか」ではなく、マイクロタスクとして戻ってくるか、タスク(マクロタスクとも呼ばれます)をまたぐかという違いにあったのです。Promise.resolve() の継続はマイクロタスクキューに積まれ、そのイベントのディスパッチが終わる前に消化されます。setTimeout(0) の継続は次のタスクに回されるので、ディスパッチはとうに終わっています。
図 2 は、この2つの継続の行き先の違いを示したものです。
defaultPrevented は「呼んだか」しか教えてくれない
ここでもう1つ、判断を誤りかけた点があります。ハンドラの内側で event.defaultPrevented と checked を読んでログに出すと、B1・B2・B3のすべてで同じ値が返ってきたのです。
B1 sync: defaultPrevented=true checked=true
B2 microtask: defaultPrevented=true checked=true
B3 timer: defaultPrevented=true checked=true
これだけを見ると、3つとも preventDefault() が効いているように見えます。ですが、これはハンドラの中で読んだ値にすぎません。チェックボックスはディスパッチの前に一度チェックされ、キャンセルされた場合はディスパッチの後に元へ戻される、という順番で動きます。ハンドラの中の時点では、まだ戻される前なので、常に true に見えてしまうのです。
ディスパッチが完全に終わった後で DOM から読み直すと、結果は分かれました。
{"B1_sync": false, "B2_microtask": false, "B3_timer": true}
defaultPrevented の値は3つとも true のままでした。つまりこのプロパティは「preventDefault() を呼んだかどうか」だけを表していて、「間に合ったかどうか」はまったく別問題なのです。この値を信用してタイミングを判断すると、B3のように既定の動作が実際に起きているケースを見落とします。
同じコードがテストと実ブラウザで逆の結果になる
検証の中でいちばん実務に効くと感じた発見がこれです。B2 のハンドラ(await Promise.resolve()の後にpreventDefault())はまったく変えず、イベントの発火のさせ方だけを変えました。
| 発火方法 | ディスパッチ後の checked | 既定の動作 |
|---|---|---|
| 実際のマウスクリック | false | 止まった |
JavaScript からの el.click() | true | 止まらなかった |
同じコードなのに結果が逆転しています。どうしてこんなことが起きるのでしょうか。
理由はマイクロタスクチェックポイントが走るタイミングにあります。マイクロタスクキューは、JavaScript の実行スタックが空になった瞬間に消化されます。実際のマウスクリックでは、リスナーはブラウザ自身のタスクから呼び出されるので、リスナーの呼び出しが終わるとスタックはすぐに空になります。そのため await の続きは、ディスパッチが完全に終わる前に滑り込めます。
ところが el.click() は、既に動いている自分のスクリプトのスタックの上から呼び出します。そのスタックが空になるのは、周りの同期コードがすべて終わった後です。await の続きは、ディスパッチがとっくに終わってから走ることになります。
図 3 は、この2つの発火経路でスタックの空き方が違うことを表しています。
これは実務上、重い意味を持ちます。テストコードで el.click() を使って確かめた挙動が、実際のユーザー操作では成立しないことがあり得ますし、その逆もあり得ます。単体テストが緑でも、実ブラウザでは壊れているかもしれない。逆に、実ブラウザでは動いているのに、テストのシミュレーションでは再現できないかもしれない。テストの発火方法そのものが、検証したい前提を壊してしまうことがあるのです。
ブラウザは動き続け、Node は止まる
最後に、送信ボタンのハンドラと同じ形のコード(確認処理を待ってから本処理を呼び、その async 関数の戻り値を誰も受け取らない、という形)を、画面ではなくサーバー側の Node.js で走らせたらどうなるかを確かめました。async 関数の戻り値を受け取らずに呼ぶ、という点はイベントハンドラとまったく同じです。
ハンドラを何も付けない場合、実行結果はこうなりました。
Error: confirm failed
at confirmThenSubmit (file:///.../node-unhandled.mjs:3:9)
...
Node.js v22.12.0
EXIT=1
100ミリ秒後に出るはずだった setTimeout のログは、そもそも出ませんでした。プロセスがその前に終了しているからです。ブラウザは unhandledrejection を発火させたあとも普通に動き続けますが、Node.js は既定で未処理の拒否をプロセスの終了として扱います。
process.on("unhandledRejection", ...) を付けると結果は変わります。
process.on(unhandledRejection) reason: Error: confirm failed
still alive after 100ms
EXIT=0
同じ書き方のコードでも、走らせる場所によって結末がまったく違うわけです。ブラウザ向けのコードを Node 環境(サーバーサイドレンダリングやバッチ処理など)へ持ち込むときは、この違いを意識しておく必要があります。
この記事の実測の範囲
本文の測定値はすべて手元で実際に動かして取ったものです。ブラウザ側は Chrome 152 と Chrome 148 の2つで同じ手順を踏み、A・Bの結果は完全に一致しました。Node.js は v22.12.0 です。
ただし Firefox と Safari では確かめていません。また、既定の動作としてはチェックボックスのトグルだけを使っています。フォームの送信やリンクのたどりといった他の既定の動作で同じ境界になるかは、この検証の範囲外です。
まとめ
async にした onClick は、便利な反面いくつかの前提を静かに崩します。ここまでの検証から持ち帰れる指針は次のとおりです。
- 送信ボタンのように、失敗したら状態を元に戻したい場面では、その処理を
catchではなくfinallyに置く。失敗の経路がtryの外にあることもあるからです - チェックボックスのように、既定の動作を止めたい場面では、
preventDefault()を呼ぶ判断をできる限り同期のうちに済ませる。今回確かめたチェックボックスでは、マイクロタスク1つぶんなら間に合いましたが、setTimeoutを挟んで次のタスクに回すと間に合いませんでした。イベントのディスパッチが1つのタスクの中で同期的に完結し、setTimeoutの継続が必ず次のタスクで走るという仕組みを踏まえると、フォーム送信やリンクのたどりといった他の既定の動作でも同じだろうと推測はできますが、実際に確かめたのはチェックボックスだけです - 同じチェックボックスの例で見たように、
event.defaultPreventedを「間に合ったかどうか」の判定に使わない。この値は呼んだかどうかしか教えてくれません - テストで
el.click()を使うときは、実際のユーザー操作と挙動が変わりうることを踏まえておく
async なイベントハンドラは書けてしまうぶん、こうした前提のずれに気づきにくいものです。一度手元で試してみると、どこで何が起きているのかが具体的に見えてくるはずです。
参考にした一次情報
- React 公式ドキュメント — Catching rendering errors with an error boundary(Error Boundary がイベントハンドラ・非同期コードの例外を対象外とする根拠)
- MDN — Window: unhandledrejection event(拒否ハンドラの無い Promise の拒否で発火する条件)
- Node.js 公式ドキュメント — Event: ‘unhandledRejection’(Node が未処理の拒否を既定でプロセス終了として扱う根拠)
- MDN — Using promises: Task queues vs. microtasks(マイクロタスクが JavaScript の実行スタックが空になった時点で消化される根拠)








