<img onError>で無限ループに陥る React の典型パターンと、その避け方の学習メモ。最後に、綴Photo で実際に使っている「ループしない書き方」も載せる。
この記事でわかること
<img onError>で state を更新するとなぜ無限ループになるか- 失敗した URL を
useRefでメモ化して再リクエストを止める方法 - 署名付き URL を扱う UI で「失効時の挙動」を最初から設計する重要性
概念
React の <img onError={...} /> は、読み込みに失敗すると呼ばれます。ここで state を更新すると、同じ壊れた URL へのリクエストが輪になります。
特に画像 URL が署名付き URL(30分失効)だと、失効した URL が画面に貼り付いた瞬間から何百回もアクセスされる、といった事故が起きます。
onError は「失敗を一回受け取るだけのコールバック」と捉えられがちだが、実際は上の 暗黙のループ経路 があります。これに気付かないと、下のような書き方をしてしまいます。
やりがちな書き方(これがループする)
onError で state を更新しても、その state を <img> の出し分けに使っていない(=同じ src の <img> を描き続ける)と、上の輪に入ります。下は説明用の最小例。
// ループする書き方(説明用の最小例)
const [failedSrc, setFailedSrc] = useState<string | null>(null); // 失敗した src を覚える箱。…なのに下の <img> で使っていない(ここが罠)
return (
<img
src={resolveImageSrc(photo)} // 失敗しても同じ src を出し続ける(failedSrc で出し分けていない)
onError={(e) => {
setFailedSrc(e.currentTarget.src);
}}
/>
);なぜ危険か
- CDN の料金爆発 / 監視アラート誤発火: 1 端末が数百 req/分を出すので、同時に複数ユーザーが壊れたページを開くだけで負荷警告が出ます
- 体感の重さ: モバイル回線で特に遅いです
- ログ可視性の低下: Sentry 等で同一エラーが山ほど積まれて、本当に対応すべき別エラーが埋もれます
避け方①: 失敗 URL をメモ化する(一般的な対策)
「失敗した URL」を useRef にメモ化して、同じ URL には二度目を投げません。下も説明用の一般例。
const failedUrlsRef = useRef<Set<string>>(new Set()); // 失敗した URL を貯めるメモ(再レンダしても中身が消えない箱 = useRef)
const [isFailed, setIsFailed] = useState(false); // 画像を出すか / プレースホルダにするか の出し分けフラグ
const handleError = (e: SyntheticEvent<HTMLImageElement>) => { // 読み込み失敗時に呼ばれる
const target = e.currentTarget.src;
if (failedUrlsRef.current.has(target)) return; // 一度失敗した URL なら二度目は何もしない=ループの輪を断つ
failedUrlsRef.current.add(target); // 今回の URL を「失敗済み」に登録
setIsFailed(true); // 画像をやめてプレースホルダ表示へ
};
return isFailed
? <PhotoPlaceholder /> // 失敗後はプレースホルダ(もう <img> を出さない=読みに行かない)
: <img src={resolveImageSrc(photo)} onError={handleError} />;避け方②: state を「画像を出すかどうか」の判定に使う(綴Photo の実装)
綴Photo の写真表示コンポーネントでは、もっと素朴に「失敗したら <img> 自体を消す」書き方にしています。失敗した src を state に持ち、それを 画像を描画するかどうかの条件に使います。
// 綴Photo の書き方(要点だけ抜粋)
const [failedSrc, setFailedSrc] = useState<string | null>(null); // 失敗した src を覚える箱(今度はちゃんと下の条件で使う)
const resolvedSrc = resolveImageSrc(photo); // 出したい画像の URL
const hasImage = Boolean(resolvedSrc) && failedSrc !== resolvedSrc; // 「画像を出すか」の条件: URL がある & そのURLがまだ失敗していない
return hasImage
// 失敗すると failedSrc === resolvedSrc になり hasImage が false → 下のプレースホルダに切り替わる
? <img src={resolvedSrc} onError={() => setFailedSrc(resolvedSrc)} />
: <PhotoPlaceholder />; // 失敗した瞬間 <img> 自体が消える=再マウントしない=ループに入らない「失敗 URL をメモ化する」より素直で、画像1枚を出すだけのコンポーネントならこれで十分だった。
学びポイント
onErrorは「通知の受け口」ぐらいに捉え、絶対に再レンダ経由で再読み込みを誘発しません- 署名付き URL を扱う UI は「失効時の挙動」を最初から設計する。失効 → 新しい URL を要求 → 差し替え、までをワンセットに
- 画像は「見えるところ」に出る分、事故が起きるとユーザーの目に直撃します
おわりに
同じループは、<img> 以外でも起こりえます(例: <video> の onError、<iframe> の onLoad)。一度パターンを覚えておくと、似た構造のバグを早めに見つけられます。
「state を更新するかどうか」を決める前に、「この変更が再マウントを引き起こさないか」を一拍考える ことが効きます。
この記事は2026年4月時点の自分の理解で書いています。React の
useRef仕様は安定していますが、最新の公式ドキュメントも合わせて参照してください。