紡Book の勉強ノートに戻る
技術ブログ2026.04.16

React の img onError を再レンダで使うと無限ループになる話

コードレビューReactperformance

<img onError> で無限ループに陥る React の典型パターンと、その避け方の学習メモ。最後に、綴Photo で実際に使っている「ループしない書き方」も載せる。


この記事でわかること

  • <img onError> で state を更新するとなぜ無限ループになるか
  • 失敗した URL を useRef でメモ化して再リクエストを止める方法
  • 署名付き URL を扱う UI で「失効時の挙動」を最初から設計する重要性

概念

React の <img onError={...} /> は、読み込みに失敗すると呼ばれます。ここで state を更新すると、同じ壊れた URL へのリクエストが輪になります。

失敗の記録は React state(メモリ)。DB にもファイルにも保存しない。画面の <img>同じ壊れた src のままonErrorReact stateメモリ・保存しない再レンダHTTP リクエスト同じ URL へ何度も飛ぶ同じ src の <img> が再マウントされ、また失敗するsrc は変えていない。失敗の記録は出し分けに使っていない

特に画像 URL が署名付き URL(30分失効)だと、失効した URL が画面に貼り付いた瞬間から何百回もアクセスされる、といった事故が起きます。

onError は「失敗を一回受け取るだけのコールバック」と捉えられがちだが、実際は上の 暗黙のループ経路 があります。これに気付かないと、下のような書き方をしてしまいます。

やりがちな書き方(これがループする)

onError で state を更新しても、その state を <img> の出し分けに使っていない(=同じ src の <img> を描き続ける)と、上の輪に入ります。下は説明用の最小例。

tsx
// ループする書き方(説明用の最小例)
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 には二度目を投げません。下も説明用の一般例。

tsx
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 に持ち、それを 画像を描画するかどうかの条件に使います。

tsx
// 綴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 はどれもコンポーネントのメモリ。DB にもファイルにも保存しない。失敗の記録画面の <img>結果罠React state に書くメモリ・条件には使わない同じ src のまま出す再マウントするHTTP がループ保存しない・何度も飛ぶ避け方①一般例useRef(再レンダしない)isFailed の state出さないisFailed で消すプレースホルダ読みに行かない避け方②綴PhotoReact state を条件に使うメモリ・保存しないアンマウントする同じ src を出さないプレースホルダ読みに行かない輪を断つのは失敗の記録ではなく、<img> を消すことである

「失敗 URL をメモ化する」より素直で、画像1枚を出すだけのコンポーネントならこれで十分だった。

学びポイント

  • onError は「通知の受け口」ぐらいに捉え、絶対に再レンダ経由で再読み込みを誘発しません
  • 署名付き URL を扱う UI は「失効時の挙動」を最初から設計する。失効 → 新しい URL を要求 → 差し替え、までをワンセットに
  • 画像は「見えるところ」に出る分、事故が起きるとユーザーの目に直撃します


おわりに

同じループは、<img> 以外でも起こりえます(例: <video> の onError、<iframe> の onLoad)。一度パターンを覚えておくと、似た構造のバグを早めに見つけられます。

「state を更新するかどうか」を決める前に、「この変更が再マウントを引き起こさないか」を一拍考える ことが効きます。


この記事は2026年4月時点の自分の理解で書いています。React の useRef 仕様は安定していますが、最新の公式ドキュメントも合わせて参照してください。