定義
画像を画面に表示するためにブラウザメモリ上のデータ(blob)を <img> に渡すとき、URL.createObjectURL(blob) で一時的なアドレスを作って使う。この一時アドレスを React コンポーネントで useMemo で生成し、useEffect のクリーンアップ関数で URL.revokeObjectURL により解放すると、画面が何度もレンダリングされるときに「すでに破棄したアドレスを再び <img> にセットする」という順序のもつれが生じ、画像が断続的に表示されなくなる。本書ではそのレース(競合状態)がなぜ起きるのか、そしてサーバーが URL を提供する画像はそもそも blob・objectURL なしに直接 URL でレンダリングして問題を根本から取り除く方法を扱う。
ここで objectURL は blob(ブラウザメモリに入った二進データの塊)を指す一時的な文字列アドレスであり、revokeObjectURL で解放して初めてそのデータがメモリから解放される。レース(race condition) は、二つの動作の実行順序が保証されず結果がばらつく状態を指す。
なぜ必要か
まず症状から見よう。画像がリロードのたびに「あるときは見え、あるときは見えない」。コンソールにネットワークエラーはない。サーバー・CDN をいくら調べてもファイルは正常だ。原因はサーバーではなく クライアントのレンダリングタイミングにある。
React は開発モード(StrictMode)でコンポーネントをわざと二度レンダリングし、並行レンダリングではコミットを準備してから捨てることもある。このとき objectURL の生成が useMemo(計算キャッシュ)に、解放が useEffect(クリーンアップフック)に分かれていると、「クリーンアップで revoke → 次のレンダリングでその URL を再び <img> にセット」といった順序がずれる。<img> がすでに解放された blob アドレスを指すと空の画像が表示される。再現がばらつくためデバッグが特に難しい。
動作原理
useMemo は値を再計算しないための キャッシュにすぎず、生成と後始末というライフサイクルを管理するための道具ではない。だからここに createObjectURL のような副作用を入れると、いつ解放されるかを React は保証しない。
StrictMode / 並行レンダリングで:
render #1 → useMemo: createObjectURL(blob) = "blob:abc"
effect #1 cleanup → revokeObjectURL("blob:abc") ← アドレス破棄
render #2 → useMemo が同じ blob に "blob:abc" を再利用(キャッシュ)
<img src="blob:abc"> ← すでに破棄されたアドレス → 崩れる
核心は、生成と解放が 別々のフックにあり、コミット/アンマウントのサイクルと対にならないことだ。一方、サーバーが配信する画像(例: /media/photo/123 のようなプロキシパス)はすでに安定した URL なので、blob→objectURL 変換という中間ステップそのものが不要であり、レースの原因になる。
実務適用
原則 1 — サーバー URL 画像は objectURL なしに直接レンダリングする。
// bad: サーバーが提供する画像をわざわざ blob→objectURL で包む
const url = useMemo(() => URL.createObjectURL(blob), [blob]);
useEffect(() => () => URL.revokeObjectURL(url), [url]);
return <img src={url} />;
// good: サーバープロキシ / 公開 URL は直接
return <img src={`/media/photo/${id}`} />;
原則 2 — objectURL が避けられないなら、生成と解放を同じ effect にペアリングする。
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const u = URL.createObjectURL(blob);
setUrl(u);
return () => URL.revokeObjectURL(u); // 同じ effect で生成・解放
}, [blob]);
原則 3 — prop 変更時の state リセットは effect ではなく、レンダリング中の前 prop 比較で行う。
// bad: react-hooks/set-state-in-effect の警告
useEffect(() => setStage("primary"), [id]);
// good: React 公式の escape hatch
const [seenId, setSeenId] = useState(id);
if (id !== seenId) {
setSeenId(id);
setStage("primary");
}
原則 4 — ロード失敗のフォールバックは <img onError> のステージマシンにする。 <picture><source type="image/webp"> はブラウザが webp を サポートしているかだけを見てソースを選ぶ。サポートしていてもそのファイルが 404 ならフォールバックしない。実際のロード失敗までフォールバックするには、onError で webp → 原本 → null の順序を自分で駆動しなければならない。
トレードオフ
- 直接 URL レンダリング: レースが消え、ブラウザ・CDN・Service Worker のキャッシュをそのまま受けられる。代わりに、サーバーがその画像を URL で配信できる必要がある(多くはプロキシルートで解決)。
- objectURL: ファイル選択のプレビュー、完全オフラインの blob、ダウンロードのトリガーのように ネットワーク URL がない場合には依然として正解だ。このときは生成・解放を必ず一つの effect にまとめる。
使ってはいけない場合
- サーバーがすでに URL で提供する画像を blob で包むこと — 純粋な損(レース + キャッシュ不可)。
useMemo/useCallbackの中でcreateObjectURL・購読・タイマーといった副作用を実行すること — 後始末のタイミングが保証されない。
よくある間違い
- 生成を
useMemo、解放をuseEffectに分離 → StrictMode/並行レンダリングで断続的に崩れる。 - 解放をまったくせず blob がメモリに蓄積(リーク)。
- prop が変わるときの状態リセットを effect で処理し、追加レンダリング・警告を誘発。
<picture>が 404 フォールバックまでしてくれると誤解。
関連概念
- race-safe-async-ui-requests — レンダリング・非同期のタイミングにリソースの寿命が絡むときの一般的なレース
- server-image-proxy-transcoding-cache — サーバーが安定した画像 URL を提供するプロキシ層
- ssr-hydration-mismatch — サーバーレンダリングの画像には onError フォールバックを掛けられないので原本を維持