ReactでIntersectionObserverを使い、無限スクロールを作る方法

​​React​IntersectionObserver,Playwright,TypeScript

一覧の最後に目印の要素を1つ置き、IntersectionObserver でその要素を見張ります。目印が画面に入ったら、次のページを読み込みます。これが無限スクロールの基本の形です。scroll イベントでスクロール位置を計算する必要はありません。

検証環境で試したところ、次の節の書き方で、5ページ100件を重複なしに最後まで読み込めました。一方、見張りを最初に1回だけ作る書き方では、同じページを何度も読む失敗と、1ページ目で画面が埋まらないと読み込みが止まる失敗が起きました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 6.0.3、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild でビルドし、Chromium 141(Playwright 1.56.1 で操作)で動かしました。動きを調べたときの画面の大きさは 400×600px で、GIF を撮ったときの画面は 400×520px です。

目印が見えたら、次のページを読む

検証では、サーバーの代わりに次の関数を使いました。300ms 待ってから、指定したページの猫の一覧を返します。全部で5ページです。window.requests は、どのページを読みに行ったかを記録するための検証用の配列です。

ts
export type Cat = { id: number; name: string };
export type CatPage = { items: Cat[]; hasMore: boolean };

const TOTAL_PAGES = 5;

// 検証用の「サーバー」。300ms 待ってから、page ページ目の pageSize 件を返す
export async function fetchCats(page: number, pageSize: number): Promise<CatPage> {
  window.requests.push(page);
  await new Promise((resolve) => setTimeout(resolve, 300));
  const items = Array.from({ length: pageSize }, (_, i) => {
    const id = (page - 1) * pageSize + i + 1;
    return { id, name: `猫 ${id}` };
  });
  return { items, hasMore: page < TOTAL_PAGES };
}

declare global {
  interface Window { requests: number[] }
}
window.requests = [];

無限スクロールの一覧は、次のように書きました。

tsx
import { useEffect, useRef, useState } from 'react';
import { fetchCats, type Cat } from './api';

type Props = { pageSize?: number; rootMargin?: string };

export function InfiniteList({ pageSize = 20, rootMargin = '0px 0px 200px 0px' }: Props) {
  const [items, setItems] = useState<Cat[]>([]);
  const [nextPage, setNextPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // 読み込み中と、最後まで読んだあとは見張らない
    if (loading || !hasMore) return;
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (!entries[0].isIntersecting) return;
        observer.disconnect(); // 1回の交差で読むのは1ページだけ
        setLoading(true);
        fetchCats(nextPage, pageSize).then((res) => {
          setItems((prev) => [...prev, ...res.items]);
          setNextPage(nextPage + 1);
          setHasMore(res.hasMore);
          setLoading(false);
        });
      },
      { rootMargin },
    );
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [nextPage, loading, hasMore, pageSize, rootMargin]);

  return (
    <div>
      <ul>
        {items.map((cat) => (
          <li key={cat.id} className="row">{cat.name}</li>
        ))}
      </ul>
      {/* 一覧の最後に置く目印。これが見えたら次のページを読む */}
      <div ref={sentinelRef} className="sentinel" />
      {loading && <p className="status">読み込み中…</p>}
      {!hasMore && <p className="status">これ以上ありません</p>}
    </div>
  );
}

目印は、高さ 1px の空の div です。一覧の1行は、CSS で高さ 40px にしています。useRef で DOM の要素を受け取り、エフェクトの中で IntersectionObserver に渡しています。useEffect の公式リファレンスにある、要素が見えているかを追う例でも、エフェクトで IntersectionObserver を作り、クリーンアップで disconnect() しています。

この書き方で大事なのは、次の3つです。

  • 読み込み中(loading)と、最後まで読んだあと(!hasMore)は、見張りを作らない。
  • 目印が見えたら、念のため、コールバックの中ですぐに disconnect() する。setLoading(true) のあとの描画で後片づけの disconnect() も走りますが、それまでのあいだに同じ見張りから次の呼び出しが来ないようにしています。
  • 依存配列に nextPage と loading を入れる。読み込みが終わるたびに、エフェクトが新しい nextPage で見張りを作り直します。

コールバックが受け取る entries の各要素の isIntersecting は、目印が判定の範囲に入ったときに true になります。判定の範囲は、画面を rootMargin(後で説明します)の分だけ広げた四角形です。

ビルドした画面を Chromium で開き、ホイールで一番下までスクロールする操作を、5回繰り返しました。読みに行ったページと、一覧の行の数は次のとおりです。

text
  開いて1秒後: リクエストしたページ=[1,2] 行の数=40(重複なし 40) 最初=猫 1 最後=猫 40 下の表示=(なし) ページの高さ=1601 画面の高さ=600
  一番下までスクロール 1回目: リクエストしたページ=[1,2,3,4] 行の数=80(重複なし 80) 最初=猫 1 最後=猫 80 下の表示=(なし) ページの高さ=3201 画面の高さ=600
  一番下までスクロール 2回目: リクエストしたページ=[1,2,3,4,5] 行の数=100(重複なし 100) 最初=猫 1 最後=猫 100 下の表示=これ以上ありません ページの高さ=4041 画面の高さ=600
  一番下までスクロール 3回目: リクエストしたページ=[1,2,3,4,5] 行の数=100(重複なし 100) 最初=猫 1 最後=猫 100 下の表示=これ以上ありません ページの高さ=4041 画面の高さ=600

4回目と5回目のスクロールの結果も、3回目と同じでした。1ページ目から5ページ目までを1回ずつ読み、100件が重複なしに並びました。5ページ目を読んだあとは「これ以上ありません」が出て、スクロールを続けてもリクエストは増えませんでした。console にはエラーも警告も出ていません。

ホイールでスクロールしたときの様子を、GIF にしました。読み込みの様子が見えるように、この GIF では1ページを12件にし、rootMargin を '0px' にしています。上の枠、カーソルと「ホイール ↓」の表示、目印の点線は、撮影用に書き足したものです。上の枠では、変わった値だけをピンクにしています。

無限スクロールの一覧をホイールで5回スクロールする様子。目印の点線が画面に入ると「読み込み中…」が出て、件数が12件ずつ増え、5回目のリクエストのあとは「これ以上ありません」が出る
ホイールでスクロールしたときの、読み込んだ件数とリクエストの回数

目印(GIF では水色の点線で表示)が画面に入ると、リクエストが1回増えて「読み込み中…」が出ます。300ms 後に12件が足され、目印は画面の外へ押し出されます。4回目のスクロールで5ページ目を読み終え、5回目のスクロールで一番下に着くと「これ以上ありません」が見えます。目印が画面に入っても、リクエストは5回のままでした。

rootMargin で、目印が見える前に読み始める

IntersectionObserver の2つ目の引数の rootMargin は、見える範囲の判定に使う四角形を広げたり縮めたりします。CSS の margin と同じように、上・右・下・左の順に書きます。'0px 0px 200px 0px' は、画面の下を 200px 広げた範囲で判定します。

20px ずつホイールでスクロールし、読み込みが始まった瞬間に、目印の上端が画面の下端からどれだけ離れていたかを測りました。プラスの値は、目印がまだ画面の下にあることを表します。

text
  rootMargin='0px 0px 200px 0px': リクエストしたページ=[1,2,3,4,5] そのときの距離=[-600,200,200,200,200]
  rootMargin='0px': リクエストしたページ=[1,2,3,4] そのときの距離=[-600,0,-1,-1]

最初の -600 は、まだ一覧が空で、目印が画面のいちばん上にあったときの1ページ目です。2ページ目からは、下を 200px 広げた方では目印が画面の 200px 下にある時点で、'0px' の方では目印が画面の下端に来た時点(0 または -1px)で読み始めていました。同じ量(20px × 120回)のスクロールで、'0px' の方は4ページ目まで読んだところで終わっています。先に読み始めれば、一覧を見ている人が一番下に着くまでに、次のページが届きやすくなるはずです。

補足

rootMargin を 200px にした一覧は、開いた直後に2ページ目まで読みました。1ページ目の20件は 800px(20件 × 40px)なので、目印はちょうど画面の 200px 下にあり、広げた範囲の端に接していたためです。MDN の説明によると、threshold の初期値 0 では、目印が範囲の境目に接しただけでもコールバックが呼ばれます。

見張りを1回だけ作ると起きる2つの失敗

見張りを作り直さないように、最初の1回だけ IntersectionObserver を作る書き方も試しました。依存配列は、1つ目が []、2つ目が [pageSize] です。この検証では pageSize を変えないので、どちらも見張りは1回しか作られません。読み込み中かどうかは、どちらも ref で持たせています。

同じページを何度も読む

1つ目は、ページ番号を state のまま、コールバックの中で読む書き方です。

tsx
// 見張りを最初に1回だけ作る書き方。コールバックの中の nextPage は、最初の描画の値のまま
export function StaleList({ pageSize = 20 }: { pageSize?: number }) {
  const [items, setItems] = useState<Cat[]>([]);
  const [nextPage, setNextPage] = useState(1);
  const loadingRef = useRef(false);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (!entries[0].isIntersecting || loadingRef.current) return;
      loadingRef.current = true;
      fetchCats(nextPage, pageSize).then((res) => {
        setItems((prev) => [...prev, ...res.items]);
        setNextPage(nextPage + 1);
        loadingRef.current = false;
      });
    });
    observer.observe(sentinelRef.current!);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <ul>
        {items.map((cat, i) => (
          <li key={i} className="row">{cat.name}</li>
        ))}
      </ul>
      <div ref={sentinelRef} className="sentinel" />
    </div>
  );
}
text
  開いて1秒後: リクエストしたページ=[1] 行の数=20(重複なし 20) 最初=猫 1 最後=猫 20 下の表示=(なし) ページの高さ=801 画面の高さ=600
  一番下までスクロール 1回目: リクエストしたページ=[1,1,1] 行の数=60(重複なし 20) 最初=猫 1 最後=猫 20 下の表示=(なし) ページの高さ=2401 画面の高さ=600
  一番下までスクロール 2回目: リクエストしたページ=[1,1,1,1,1] 行の数=100(重複なし 20) 最初=猫 1 最後=猫 20 下の表示=(なし) ページの高さ=4001 画面の高さ=600
  一番下までスクロール 3回目: リクエストしたページ=[1,1,1,1,1,1,1] 行の数=140(重複なし 20) 最初=猫 1 最後=猫 20 下の表示=(なし) ページの高さ=5601 画面の高さ=600

スクロールするたびに1ページ目ばかりを読み、140行あっても中身は「猫 1」から「猫 20」の20種類だけでした。3回のスクロールで140行まで伸び、5ページで止まる様子はありませんでした。コールバックは、最初の描画のあとのエフェクトで1回だけ作った関数なので、その中の nextPage は、最初の描画の値 1 のままです。同じ id が並ぶため、この確認用のコードだけは key に配列の番号を使っています。

1ページ目で画面が埋まらないと読み込みが止まる

2つ目は、ページ番号も ref で持ち、コールバックの中で最新の値を読む書き方です。

tsx
// 見張りを1回だけ作り、ページ番号と読み込み中かどうかを ref で持つ書き方
export function StuckList({ pageSize = 20 }: { pageSize?: number }) {
  const [items, setItems] = useState<Cat[]>([]);
  const nextPageRef = useRef(1);
  const loadingRef = useRef(false);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (!entries[0].isIntersecting || loadingRef.current) return;
      loadingRef.current = true;
      fetchCats(nextPageRef.current, pageSize).then((res) => {
        setItems((prev) => [...prev, ...res.items]);
        nextPageRef.current += 1;
        loadingRef.current = false;
      });
    });
    observer.observe(sentinelRef.current!);
    return () => observer.disconnect();
  }, [pageSize]);
  // …(return 以下は省略。StaleList と同じで、key だけを cat.id にしています)

1ページ20件で開いて一番下までスクロールすると、読みに行くページは 1, 2, 3 と進み、1つ目の失敗は起きませんでした。

text
  開いて1秒後: リクエストしたページ=[1] 行の数=20(重複なし 20) 最初=猫 1 最後=猫 20 下の表示=(なし) ページの高さ=801 画面の高さ=600
  一番下までスクロール 1回目: リクエストしたページ=[1,2,3] 行の数=60(重複なし 60) 最初=猫 1 最後=猫 60 下の表示=(なし) ページの高さ=2401 画面の高さ=600
  一番下までスクロール 2回目: リクエストしたページ=[1,2,3,4,5] 行の数=100(重複なし 100) 最初=猫 1 最後=猫 100 下の表示=(なし) ページの高さ=4001 画面の高さ=600
  一番下までスクロール 3回目: リクエストしたページ=[1,2,3,4,5,6,7] 行の数=140(重複なし 140) 最初=猫 1 最後=猫 140 下の表示=(なし) ページの高さ=5601 画面の高さ=600

このコードは最後のページかどうか(hasMore)を確かめていないので、3回のスクロールで、5ページ目より先の7ページ目まで読みに行きました(検証用の fetchCats は、6ページ目以降も猫を返します)。実際に使うときは、InfiniteList と同じように最後のページで止める必要があります。

ところが、1ページを5件(200px)にして、高さ 600px の画面で開くと、次のようになりました。比べるために、最初の節の InfiniteList も、1ページ5件・高さ 600px の画面で開いています(rootMargin は、InfiniteList の引数の初期値 '0px 0px 200px 0px' のままです)。

text
  stuck: リクエストしたページ=[1] 行の数=5(重複なし 5) 最初=猫 1 最後=猫 5 下の表示=(なし) ページの高さ=600 画面の高さ=600
  infinite: リクエストしたページ=[1,2,3,4,5] 行の数=25(重複なし 25) 最初=猫 1 最後=猫 25 下の表示=これ以上ありません ページの高さ=1041 画面の高さ=600

StuckList は1ページ目の5件だけを出して止まりました。ページの高さが画面と同じ 600px なので、スクロールもできません。使う人には、続きを読む方法がありません。

MDN の説明によると、コールバックが呼ばれるのは、目印が見える範囲に出入りして、しきい値をまたいだときと、見張りを始めた最初の1回です。5件を足したあとも目印は画面の中にあり、見えている状態が続いているだけなので、コールバックは呼ばれません。

InfiniteList は、読み込みが終わるたびにエフェクトが見張りを作り直します。作り直した見張りは、observe() した最初の1回でコールバックを呼ぶので、目印がまだ判定の範囲(この比較では、画面と、下に広げた 200px)の中にあれば、続けて次のページを読みます。この検証では、最後のページ(5ページ目)まで読み込みました。StuckList の目印は、rootMargin で広げなくても画面の中(5件 × 40px で、上から 200px の位置)にありました。それでも読み込みは止まったので、違いを生んだのは、見張りを作り直すかどうかだと考えられます。

ポイント

IntersectionObserver のコールバックは、見える・見えないが切り替わったときにしか呼ばれません。読み込みが終わるたびに見張りを作り直すと、古いページ番号を読む失敗と、画面が埋まらないまま止まる失敗を、どちらも避けられます。

StrictMode の開発用ビルドでも、同じページは2回読まない

開発用ビルドの StrictMode では、エフェクトの準備と後片づけが1回余分に実行されます。InfiniteList を StrictMode で包んだ開発用ビルドでも、同じ手順で確かめました。

text
  開いて1秒後: リクエストしたページ=[1,2] 行の数=40(重複なし 40) 最初=猫 1 最後=猫 40 下の表示=(なし) ページの高さ=1601 画面の高さ=600
  一番下までスクロール 1回目: リクエストしたページ=[1,2,3,4] 行の数=80(重複なし 80) 最初=猫 1 最後=猫 80 下の表示=(なし) ページの高さ=3201 画面の高さ=600
  一番下までスクロール 2回目: リクエストしたページ=[1,2,3,4,5] 行の数=100(重複なし 100) 最初=猫 1 最後=猫 100 下の表示=これ以上ありません ページの高さ=4041 画面の高さ=600

3回目から5回目のスクロールの結果も、2回目と同じでした。読みに行ったページは本番用ビルドと同じで、同じページを2回読むことはありませんでした。1回目の見張りは、コールバックが呼ばれる前に、後片づけの disconnect() で止まっていたと考えられます。

この検証で試していないこと

見張る範囲は画面全体(root を指定しない)だけで確かめました。高さを決めた div の中でスクロールする一覧では、MDN の説明のとおり root にその要素を渡しますが、この検証では試していません。

また、サーバーは 300ms 待つだけの関数で、本物の通信は使っていません。読み込みに失敗したときのやり直しや、読み込み中に画面を離れたときの通信の取り消しも、この記事のコードには入れていません。Chromium 以外のブラウザと、実機のスマホも試していません。

​Posted by 86