Reactで画像を遅延読み込みし、読み込み中の表示を出す方法

​​React​Playwright,TypeScript,アクセシビリティ,サーバーレンダリング

React で画像を遅延読み込みするには、<img> に loading="lazy" を付けるだけで足ります。読み込み中の表示は、onLoad と onError で「読み込み中・完了・失敗」の状態を持ち、完了するまで画像の上に重ねて出します。あとは、画像の width と height を書くこと、写真を差し替えるときに key で作り直すこと、サーバーで描画するときに complete も確かめることの3点を押さえれば、表示が崩れたり、読み込み中の表示が残ったりしません。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、react / react-dom 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2 です。ブラウザはヘッドレスの Chromium 153 で、Playwright 1.63.0 から操作しました。サーバーで描画する場合の比較には React 18.3.1 も使っています。検証用のサーバーは、画像を返すまでの時間を URL で指定できるようにしてあり、一覧と写真の差し替えの検証では 1.5 秒遅らせています。Firefox と Safari、実際のモバイル回線では試していません。

読み込み中の表示を重ねる LazyImage コンポーネント

最初に、検証で使ったコンポーネントを載せます。状態は 'loading' | 'loaded' | 'error' の3つです。読み込みが終わるまでは、画像の上に「読み込み中…」の枠を重ねます。

tsx
import { useEffect, useRef, useState } from 'react';

type Status = 'loading' | 'loaded' | 'error';

type Props = {
  src: string;
  alt: string;
  width: number;
  height: number;
};

export function LazyImage({ src, alt, width, height }: Props) {
  const [status, setStatus] = useState<Status>('loading');
  const imgRef = useRef<HTMLImageElement>(null);

  // React が onLoad を付ける前に読み込みが終わっていたときのため
  useEffect(() => {
    const img = imgRef.current;
    if (img?.complete) {
      setStatus(img.naturalWidth > 0 ? 'loaded' : 'error');
    }
  }, []);

  return (
    <div className="frame" data-status={status}>
      {status !== 'loaded' && (
        <div className="placeholder" aria-hidden="true">
          {status === 'loading' ? '読み込み中…' : '画像を表示できません'}
        </div>
      )}
      <img
        ref={imgRef}
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading="lazy"
        decoding="async"
        onLoad={() => setStatus('loaded')}
        onError={() => setStatus('error')}
      />
    </div>
  );
}

CSS では、外側の枠を position: relative にし、読み込み中の表示を inset: 0 で枠いっぱいに重ねます。画像は読み込みが終わるまで opacity: 0 にしておき、終わったら opacity: 1 にしてふわっと出します。

css
.frame { position: relative; max-width: 480px; margin: 0 0 16px; }
.frame img { display: block; width: 100%; height: auto; opacity: 0; transition: opacity 0.3s; }
.frame[data-status="loaded"] img { opacity: 1; }
.placeholder {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: #eefafd; color: #067a98; font-size: 14px;
}

onLoad と onError は、React が <img> で受け取れるイベントとして公式リファレンスの共通コンポーネントのページに載っています。onLoad は読み込みが終わったとき、onError は読み込めなかったときに呼ばれます。

存在しない画像を指定したところ、サーバーは 404 を返し、onError で状態が error に変わって「画像を表示できません」が出ました。

text
応答 404 /img/missing.png
data-status=error、表示: 画像を表示できません、img.complete=true、naturalWidth=0

読み込み中の表示には aria-hidden="true" を付けています。見た目のための表示で、画像の説明は alt が担うため、スクリーンリーダーには読ませません。

loading="lazy" で、画面から遠い画像の読み込みを後回しにする

MDN の img 要素のページによると、loading="lazy" を付けた画像は、ブラウザが計算した距離まで画面に近づくまで読み込まれません。JavaScript で IntersectionObserver を組まなくても、ブラウザが判断してくれます。

検証では、幅 400px・高さ 600px の画面に、写真を縦に 16 枚並べました。1 枚の高さは枠の余白を含めて約 261px です。開いた直後と、スクロールした後に、どの写真が要求されたかを数えました。

text
各写真の上端(px): 58 320 581 842 1104 1365 1626 1888 2149 2410 2672 2933 3194 3456 3717 3978
  160ms 描画直後 要求済み: 14枚 [1,2,3,4,5,6,7,8,9,10,11,12,13,14]
  161ms 状態 …………………………………………
 2668ms 2.5秒後 要求済み: 14枚 [1,2,3,4,5,6,7,8,9,10,11,12,13,14]
 2668ms 状態 ○○○○○○…………………………
 2881ms 1500px までスクロールして0.2秒後 要求済み: 16枚 [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]

「状態」の行は、1 文字が写真 1 枚です。「…」は読み込み中、「○」は読み込み完了を表します。開いた直後は 16 枚すべてに読み込み中の表示が出て、14 枚目までが要求されました。15 枚目と 16 枚目は、1500px までスクロールしたところで初めて要求されています。

画面には 3 枚目がわずかに見える程度なのに、14 枚目までが最初に要求されたことになります。web.dev の記事によると、Chrome がどこまで先に読み込むかは回線の速さなどで変わり、2020 年 7 月の変更で 4G 相当なら 1250px、3G 以下なら 2500px になりました。検証環境の Chromium 153 では、navigator.connection.effectiveType は 4g でした。それなのに、30 枚に増やして測ると、1250px を大きく超えて、画面の下端から 2,856px 下に上端がある 14 枚目まで要求されました。

text
navigator.connection.effectiveType: 4g
30枚中 14枚を要求。要求された最後の写真 14 の上端 3456px、要求されなかった最初の写真 15 の上端 3717px(画面の下端は 600px)
写真 15 の img.complete: false、data-status: loading

距離はブラウザが決めるもので、web.dev の同じ記事にも、今後変わりうると書かれています。何 px 先から読み込むかに頼った作りにはせず、「画面から遠いものは後回しになる」程度に考えておくのが安全です。

width と height で、読み込み中の表示の大きさを先に決める

<img> には width と height を書きます。MDN によると、この2つがあれば、ブラウザは読み込み前に縦横の比率を計算して場所を取っておけます。検証でも、CSS で width: 100%; height: auto; にして画面幅に合わせて縮めた写真が、読み込み前から高さ 245px の場所を取っていました。

width と height のある版とない版で、読み込みの前後の位置を測りました。ある版は LazyImage の一覧で、2 枚目の枠の上端を測っています。ない版は、読み込み中の表示のない素の <img> を 3 枚並べたもので、3 枚の下に置いた文章の上端を測っています。レイアウトシフトの合計は、PerformanceObserver で layout-shift の値を足したものです。

text
width・height あり: 1枚目の高さ 読み込み前 245px → 後 245px、すぐ下の要素の上端 320px → 320px、レイアウトシフトの合計 0.000
width・height なし: 1枚目の高さ 読み込み前 0px → 後 245px、すぐ下の要素の上端 74px → 810px、レイアウトシフトの合計 0.037

MDN の img 要素のページにもあるとおり、まだ読み込まれていない画像は幅も高さも 0 です。検証環境でも、width と height がない版では、読み込む前の画像は高さ 0px でした。3 枚の読み込みが終わると、下の文章は 74px から 810px まで、写真 3 枚分押し下げられました。また、読み込み中の表示を枠いっぱいに重ねる作りでは、枠の高さが画像の高さで決まります。画像の高さが 0px のままだと、重ねた表示も見えなくなるはずです(この組み合わせは試していません)。大きさを先に決めておくことは、読み込み中の表示を出すための前提でもあります。

読み込み中の img は display: none で隠さない

読み込みが終わるまで画像を隠すとき、opacity: 0 の代わりに display: none を使うと、遅延読み込みの画像はいつまでも読み込まれません。先ほどの web.dev の記事の「よくある質問」には、Chrome・Safari・Firefox は display: none の画像を読み込まず、opacity: 0 で隠した画像は読み込むと書かれています。

読み込み中の <img> を隠す指定だけを変えて、16 枚の一覧を開きました。

text
(16枚目の img の display: block / opacity 0)
読み込み中の img を opacity: 0 で隠す: 要求 14枚、1.5秒後の状態 ○○○○○○○○○○○○○○……
(16枚目の img の display: none / opacity 0)
読み込み中の img を display: none で隠す: 要求 0枚、1.5秒後の状態 …………………………………………
注意

display: none で隠した版では、1 枚も要求されませんでした。読み込みが始まらないので onLoad が呼ばれず、状態は loading のままになり、読み込み中の表示が消えません。読み込み中の画像を隠すときは、opacity: 0 を使ってください。

写真を差し替えるときは key を付けて、状態を作り直す

同じ場所で写真を切り替える画面(「次の写真」ボタンなど)では、LazyImage に key={src} を付けます。付けないと、src が変わっても React は同じコンポーネントとして扱い、状態は前の写真の loaded のまま残ります。

tsx
<LazyImage key={src} src={src} alt={`写真 ${n}`} width={480} height={320} />

React の公式ドキュメントにある、key で state をリセットする方法の説明のとおり、key が変わると React は別のコンポーネントとして作り直します。status も 'loading' に戻ります。同じページの練習問題の解答にも、<img> に key を付ける例があります。key が変わると <img> の DOM 要素から作り直されるので、画像を読み込むたびに一瞬ちらつきます。そのため、すべての画像に付けるものではないと説明されています。

写真 1 を表示した状態で「次の写真」を押し、0.3 秒後と 2 秒後の様子を比べました。

text
[key なし] 切り替え前: data-status=loaded、読み込み中の表示 なし、img の src /img/photo1.png、currentSrc /img/photo1.png、complete=true
[key なし] 押して0.3秒後: data-status=loaded、読み込み中の表示 なし、img の src /img/photo2.png、currentSrc (空)、complete=false
[key なし] 押して2秒後: data-status=loaded、読み込み中の表示 なし、img の src /img/photo2.png、currentSrc /img/photo2.png、complete=true
[key={src}] 切り替え前: data-status=loaded、読み込み中の表示 なし、img の src /img/photo1.png、currentSrc /img/photo1.png、complete=true
[key={src}] 押して0.3秒後: data-status=loading、読み込み中の表示 あり、img の src /img/photo2.png、currentSrc (空)、complete=false
[key={src}] 押して2秒後: data-status=loaded、読み込み中の表示 なし、img の src /img/photo2.png、currentSrc /img/photo2.png、complete=true

key がないと、src はもう写真 2 なのに、状態は loaded のままでした。押して 0.3 秒後の画面には、読み込み中の表示ではなく、前の写真 1 がそのまま映っていました。

上は key を付けない場合で、src は写真 2 なのに写真 1 が出たまま。下は key={src} を付けた場合で、読み込み中の表示が出ている
「次の写真」を押して0.3秒後の画面。上が key なし、下が key={src}

key={src} を付けた版で「次の写真」を3回押した様子です。押すたびに読み込み中の表示に変わり、1.5 秒ほどで次の写真が出ます。

「次の写真」ボタンを3回押すと、そのたびに状態が「読み込み中」になって枠に「読み込み中…」が出て、その後「読み込み完了」になって写真 2、3、4 が順に出る様子
key={src} を付けた版で「次の写真」を3回押したときの動き

画面上部の帯、カーソル、クリックしたときの輪、「状態」の行は、撮影のために足したものです。状態が変わった瞬間だけ、ピンクで強調しています。

サーバーで描画するなら、complete も確かめる

ここまでは、ブラウザの中だけで描画する createRoot の場合です。サーバーで HTML を作り、ブラウザで hydrateRoot によってハイドレーションする場合は、注意が要ります。HTML に書かれた <img> は、ハイドレーションを待たずにブラウザが読み込みます。ハイドレーションより前に読み込みが終わると、load イベントはもう起きた後なので、onLoad は呼ばれません。

検証では、サーバーで描画した 16 枚の一覧を、わざと 1.5 秒遅らせてハイドレーションしました。画像はすぐに返すようにしています。先ほどの LazyImage から、complete を確かめる useEffect だけを外した版と比べています。

text
# React 19
[complete の確認 なし] hydrate 前(1枚目〜6枚目): loading(complete) loading(complete) loading(complete) loading(complete) loading(complete) loading(complete)
[complete の確認 なし] hydrate して1秒後: loading(complete) loading(complete) loading(complete) loading(complete) loading(complete) loading(complete)
[complete の確認 なし] 読み込み中の表示: 16個 / 16枚、コンソールのエラー・警告 0件
[complete の確認 なし] いちばん下までスクロールして1秒後の読み込み中の表示: 14個 / 16枚
[complete の確認 あり] hydrate 前(1枚目〜6枚目): loading(complete) loading(complete) loading(complete) loading(complete) loading(complete) loading(complete)
[complete の確認 あり] hydrate して1秒後: loaded(complete) loaded(complete) loaded(complete) loaded(complete) loaded(complete) loaded(complete)
[complete の確認 あり] 読み込み中の表示: 2個 / 16枚、コンソールのエラー・警告 0件
[complete の確認 あり] いちばん下までスクロールして1秒後の読み込み中の表示: 0個 / 16枚

complete を確かめない版では、画像はとっくに読み込み終わっている(complete)のに、ハイドレーションの後も状態が loading のままでした。読み込み中の表示が 16 枚すべてに残り、エラーも警告も出ません。いちばん下までスクロールすると、2 枚だけ表示が消えました。どの写真かは記録していませんが、開いた直後に要求されなかった 15 枚目と 16 枚目だと考えられます。ハイドレーションの後に読み込まれた画像なら、onLoad が呼ばれるからです。

React 18.3.1 でも、同じ結果になりました。load イベントが届いた回数を数えると、React 19 でも 18 でも、14 回すべてがハイドレーションの前で、後に届いたものはありませんでした。1 枚目と 2 枚目は、ページを開いて 50ms 以内に読み込みが終わっています。ハイドレーションの後に load が 1 回も届かなかったので、React が画像の読み込みをやり直すこともありませんでした。出力の「src の代入」は、img.src への代入だけを数えたものです。

text
# React 19
37ms load 写真 1
47ms load 写真 2
load の合計 14回(hydrate 後 0回)、src の代入 0回

# React 18
33ms load 写真 1
33ms load 写真 2
load の合計 14回(hydrate 後 0回)、src の代入 0回

そこで、useEffect の中で HTMLImageElement.complete を確かめます。MDN によると、complete は読み込みが終わったときだけでなく、読み込みに失敗したときも true になります。そのため、naturalWidth が 0 より大きいかで、成功と失敗を分けています。404 の画像では complete=true、naturalWidth=0 でした。

まだ読み込みが始まっていない遠い画像は、complete が false です。30 枚の一覧で要求されなかった 15 枚目も false だったので、まだ読み込みが始まっていない画像を、誤って「完了」にすることはありません。

遅延読み込みと読み込み中の表示のポイント

ポイント

  • <img loading="lazy"> で、画面から遠い画像の読み込みはブラウザが後回しにする
  • onLoad と onError で状態を持ち、完了するまで読み込み中の表示を重ねる
  • width と height を書いて、読み込み前から枠の大きさを決める
  • 読み込み中の画像は opacity: 0 で隠す。display: none では読み込まれない
  • 写真を差し替えるときは key={src} で状態を作り直す
  • サーバーで描画するなら、useEffect で complete と naturalWidth も確かめる

ここまでをそろえれば、IntersectionObserver を使わなくても、遅延読み込みと読み込み中の表示は <img> の属性と小さな状態だけで作れます。

​Posted by 86