紡Book の勉強ノートに戻る
開発記録2026.07.08

画像の読み込み失敗は親まで伝わってこない — error はバブルしない性質で1点にまとめる

ReactDOMエラーハンドリング紡Book

共有ページに「写真が読み込めなかったときの受け皿」を付ける途中の話。受け皿を"どこに付けるか"で詰まって、DOM の地味な性質に助けられた。

綴Photo の共有ページは、写真本体を一定時間で失効する署名付きURLで配っています。失効すると画像が無言で割れる——この「失効には受け皿(失敗の検知・取り直し・案内)をセットで作る」という話自体は、前に「時間で切れるリンクで写真を配ると、切れた瞬間が無言になる」という記事で書いた。今回はその受け皿を実際に実装へ落とす番で、詰まったのは思想じゃなく「どこに付けるか」だった。(この共有ページは2026年7月時点ではまだ実験ルート上にあって、一般公開はしていません。)

受け皿を付けようとして、<img> が手元に無い

受け皿の付け先は画像の <img> です。読み込み失敗=<img> の onError。だから共有ビューの <img> に onError を足せばいい、と思っていた。ところが共有ビュー(ShareView)を開くと、<img> がどこにも無い。

共有ページは、合言葉を入れて開いたあとは、写真を「見せ方のテンプレート(写真をどう並べて見せるかの型)」に渡して描くだけになっています。実際に <img> を書いているのは、そのテンプレートの中だった。

受け皿を付けたい img はテンプレの中。共有ビューは箱だけ。DOM はメモリ。保存しない。共有ビューのコンテナ渡すのは title と photos。どれで描くかだけ決める。フラットな定番<img>ここで描く誌面<img>ここで描く縦スクロール<img>ここで描く

これは事故でこうなったわけじゃなく、そう作った結果だった。テンプレートに渡すプロップ(親から子へ値を渡す口)は title と photos(+章立てが要るテンプレートだけ sections)の3つだけ、と意図的に絞ってあります。テンプレートを「写真と言葉を受け取って、自分の見せ方で描くだけの箱」にしておきたかったからだ。しかも <img> は、テンプレートごとに自前のクラスで角丸やトリミングを当てていて、1枚目だけ先に読み込んで残りは遅延、みたいな出し分けも中で決めています。<img> は「テンプレートの都合のかたまり」で、親に引き上げようがありません。手が届かないのは、届かないように作ったからだった。

onError を配って回る、をやめた

素直にやるなら、各テンプレートに onError を渡すプロップを1個ずつ足して、<img> まで配線して回ります。でも手を止めた。テンプレートは今後も増える前提の作りで、1枚ずつ onError を通して回ると、新しいテンプレートを足すたびに配線を忘れれば、そこだけ受け皿が抜けます。受け皿を付けたいのに、受け皿の付け忘れを新しく生む形になります。共有ビューは全テンプレートの親なのに、子が握っている <img> に手が届かないのが気持ち悪かった。

error はバブルしない、を逆手に取る

親から子孫のイベントをまとめて拾う、で最初に浮かぶのはイベント委譲(親で1回拾う)です。ただ画像の読み込み失敗にはクセがあります。

error は img で止まって登らない。下りならコンテナを必ず通る。DOM はメモリ。保存しない。帰り(既定)届かない行き(第3引数 true)拾える共有ビューのコンテナ登ってこないテンプレ登ってこない<img> で error。ここで止まる共有ビューのコンテナリスナ1個。ここで拾う必ず通るテンプレ(通過)<img> で error

コンテナに付ける形は AI に組んでもらった。要はこれだけだ。

tsx
// 共有ビュー側。viewerRef は写真テンプレートをまるごと包むコンテナ(外側の箱)を指している
useEffect(() => {                  // 画面が表示されたあとに1回だけ実行される
  const el = viewerRef.current;    // その「箱」の実体を取り出す
  if (!el || !payload) return;     // 箱も中身もまだ無いうちは何もしない
 
  function handleImageError(ev: Event) {
    // capture で広く拾う代わりに、<img> 以外の読み込み失敗も同じ所に来る。だから発生源を確かめる
    const target = ev.target as HTMLElement | null;
    if (!target || target.tagName !== 'IMG') return;
    // …ここで合言葉を使って署名URLを1回だけ取り直す。ダメなら終端の案内へ切り替える
  }
 
  // 第3引数 true = capture フェーズ。error はバブルしないので、これで子孫の <img> を1点で拾える
  el.addEventListener('error', handleImageError, true);
  // 画面を離れるときに見張りを外す後片付け(付けっぱなしだと二重に拾う)
  return () => el.removeEventListener('error', handleImageError, true);
}, [payload, token, input]);       // この3つが変わったら、貼り直す

拾ったあとの「1回だけ取り直す・上限を切る・ダメなら静かな案内」の作りは、冒頭で触れた別記事(「時間で切れるリンクで写真を配ると、切れた瞬間が無言になる」)に書いた考え方そのままなので、ここでは省く。ただ1点で拾う形にしたことで、足さざるを得なかったものが2つあった。

ひとつは、取り直しを1回に絞るための鍵。署名URLが切れるときは1枚だけじゃなく、画面に出ている写真が一斉に切れるはずで、onError を配って回っていれば「その1枚の担当」で済んだものが、1点で拾うと枚数分の失敗が同じ1個のリスナに雪崩れ込みます。素通しにすると取り直しが枚数分走ります。

取り直し中かはメモリの ref。DB にも prefs にも書かない。画面を閉じたら消える。<img> error<img> error<img> error共有ビューのコンテナ。ここで1点で拾ういま取り直し中か(メモリ)空き → 取り直し中 → 済取り直し中なら2枚目以降は捨てる。保存しない。描き直しのスナップショットは交差。置き場ではない。リスナから見ると古い値のまま。

もうひとつは、自分で addEventListener したリスナは React の描き直しとは切り離されて生き続けることです。1点で拾う形にした時点で、状態の持ち方もこちら側に引き受けていた。

タダで1点になったわけではなく、テンプレート側から取り上げた責任がそのまま親に乗った、というのが実際の手触りだった。

結果:テンプレートを一枚も触らずに済んだ

テンプレートのコードは差分ゼロ。新しいテンプレートを足しても、コンテナの下に入るなら勝手に受け皿の内側に入ります。「付け忘れで穴が空く」心配が構造から消えた。

学んだこと

今回効いたのは、イベントが基本バブリングするなかで error / load / abort みたいにバブリングしないものがある、という一点だった。「親で1回拾う」を素直にやると、これらだけすり抜けます。そのときは addEventListener の第3引数を true にした capture フェーズ。バブリングしない子孫のイベントも、降りていく道でなら親がまとめて拾えます。

「同じ処理を要素の数だけ配って回りそうになったら、1点で拾えないか疑います」。今回はそれで配線が要らなくなったうえに、テンプレートを足したときの付け忘れも気にしなくてよくなった。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。