共有ページに「写真が読み込めなかったときの受け皿」を付ける途中の話。受け皿を"どこに付けるか"で詰まって、DOM の地味な性質に助けられた。
綴Photo の共有ページは、写真本体を一定時間で失効する署名付きURLで配っています。失効すると画像が無言で割れる——この「失効には受け皿(失敗の検知・取り直し・案内)をセットで作る」という話自体は、前に「時間で切れるリンクで写真を配ると、切れた瞬間が無言になる」という記事で書いた。今回はその受け皿を実際に実装へ落とす番で、詰まったのは思想じゃなく「どこに付けるか」だった。(この共有ページは2026年7月時点ではまだ実験ルート上にあって、一般公開はしていません。)
受け皿を付けようとして、<img> が手元に無い
受け皿の付け先は画像の <img> です。読み込み失敗=<img> の onError。だから共有ビューの <img> に onError を足せばいい、と思っていた。ところが共有ビュー(ShareView)を開くと、<img> がどこにも無い。
共有ページは、合言葉を入れて開いたあとは、写真を「見せ方のテンプレート(写真をどう並べて見せるかの型)」に渡して描くだけになっています。実際に <img> を書いているのは、そのテンプレートの中だった。
これは事故でこうなったわけじゃなく、そう作った結果だった。テンプレートに渡すプロップ(親から子へ値を渡す口)は title と photos(+章立てが要るテンプレートだけ sections)の3つだけ、と意図的に絞ってあります。テンプレートを「写真と言葉を受け取って、自分の見せ方で描くだけの箱」にしておきたかったからだ。しかも <img> は、テンプレートごとに自前のクラスで角丸やトリミングを当てていて、1枚目だけ先に読み込んで残りは遅延、みたいな出し分けも中で決めています。<img> は「テンプレートの都合のかたまり」で、親に引き上げようがありません。手が届かないのは、届かないように作ったからだった。
onError を配って回る、をやめた
素直にやるなら、各テンプレートに onError を渡すプロップを1個ずつ足して、<img> まで配線して回ります。でも手を止めた。テンプレートは今後も増える前提の作りで、1枚ずつ onError を通して回ると、新しいテンプレートを足すたびに配線を忘れれば、そこだけ受け皿が抜けます。受け皿を付けたいのに、受け皿の付け忘れを新しく生む形になります。共有ビューは全テンプレートの親なのに、子が握っている <img> に手が届かないのが気持ち悪かった。
error はバブルしない、を逆手に取る
親から子孫のイベントをまとめて拾う、で最初に浮かぶのはイベント委譲(親で1回拾う)です。ただ画像の読み込み失敗にはクセがあります。
コンテナに付ける形は AI に組んでもらった。要はこれだけだ。
// 共有ビュー側。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個のリスナに雪崩れ込みます。素通しにすると取り直しが枚数分走ります。
もうひとつは、自分で addEventListener したリスナは React の描き直しとは切り離されて生き続けることです。1点で拾う形にした時点で、状態の持ち方もこちら側に引き受けていた。
タダで1点になったわけではなく、テンプレート側から取り上げた責任がそのまま親に乗った、というのが実際の手触りだった。
結果:テンプレートを一枚も触らずに済んだ
テンプレートのコードは差分ゼロ。新しいテンプレートを足しても、コンテナの下に入るなら勝手に受け皿の内側に入ります。「付け忘れで穴が空く」心配が構造から消えた。
学んだこと
今回効いたのは、イベントが基本バブリングするなかで error / load / abort みたいにバブリングしないものがある、という一点だった。「親で1回拾う」を素直にやると、これらだけすり抜けます。そのときは addEventListener の第3引数を true にした capture フェーズ。バブリングしない子孫のイベントも、降りていく道でなら親がまとめて拾えます。
「同じ処理を要素の数だけ配って回りそうになったら、1点で拾えないか疑います」。今回はそれで配線が要らなくなったうえに、テンプレートを足したときの付け忘れも気にしなくてよくなった。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。