無限スクロールが解く問題

無限スクロール(Infinite Scroll)は、ユーザーがリストの末尾に近づいたタイミングで次のデータを自動読み込みするUIパターンです。 Twitter/X のタイムライン、Instagramのフィード、商品一覧の「続きを表示」など、コンテンツが連続する画面で広く採用されています。

技術的な本質は「ページネーションの自動化」です。 APIは依然として?page=2?cursor=abcのようにページ単位でデータを返しますが、 フロント側はユーザーの明示的なクリックを待たず、スクロール位置から推測して次ページを取りに行きます。

ページネーションとの比較

観点 ページネーション 無限スクロール
操作コスト クリックが必要 スクロールのみで連続閲覧
現在位置の把握 「3/20ページ」等で明確 曖昧になりがち
フッター到達性 各ページで必ず到達可能 到達困難(遠ざかる)
ブラウザバック URLで位置復元しやすい 位置復元に一手間必要
SEO・ディープリンク 得意 苦手(工夫が必要)
向くコンテンツ 検索結果・管理画面・アーカイブ SNS・レコメンド・探索型フィード

実装方式の選択肢

React + TypeScriptで無限スクロールを実装するアプローチは、歴史的に以下の順で進化してきました。

  1. scrollイベント + スロットリング: window.scrollYdocument.body.scrollHeightを比較。愚直だがイベント頻度が高く、再計算コストとジャンク(カクつき)が出やすい。
  2. IntersectionObserver: リスト末尾にセンチネル(監視用の空要素)を置き、それが画面に入った瞬間だけコールバックが呼ばれる。ブラウザ側で効率的に監視されるためCPU負荷が低い。2020年代の事実上の標準
  3. IntersectionObserver + useInfiniteQuery: 交差検知はIntersectionObserver、データフェッチ・キャッシュ・ページ管理はTanStack Queryに分業させる。現在の最短経路。

本記事では2と3を中心に扱います。古い記事で見かけるreact-infinite-scroll-componentのようなラッパーライブラリは、今はIntersectionObserverを薄く包んだカスタムフックで置き換えるのが一般的です。

データフローの全体像

sequenceDiagram
    participant User
    participant Sentinel as センチネル要素
    participant IO as IntersectionObserver
    participant Hook as useInfiniteQuery
    participant API
    User->>Sentinel: スクロールで接近
    Sentinel->>IO: 交差を検知
    IO->>Hook: fetchNextPage
    Hook->>API: GET items with cursor
    API-->>Hook: data と nextCursor
    Hook->>Hook: キャッシュに追記
    Hook-->>User: リスト末尾を再レンダリング

IntersectionObserverによる最小実装

まずは外部ライブラリに頼らない素のReact実装を見ていきます。 要点は「リスト末尾にセンチネルを置き、交差をuseEffectで監視する」ことです。

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

type Post = { id: number; title: string };

async function fetchPosts(page: number): Promise<{ items: Post[]; hasMore: boolean }> {
  const res = await fetch(`/api/posts?page=${page}`);
  return res.json();
}

export function PostList() {
  const [pages, setPages] = useState<Post[][]>([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  // 交差時に次ページを読み込む
  useEffect(() => {
    const el = sentinelRef.current;
    if (!el || !hasMore) return;

    const io = new IntersectionObserver(
      async ([entry]) => {
        if (!entry.isIntersecting || isLoading) return;
        setIsLoading(true);
        const { items, hasMore: next } = await fetchPosts(page);
        setPages((prev) => [...prev, items]);
        setHasMore(next);
        setPage((p) => p + 1);
        setIsLoading(false);
      },
      { rootMargin: '200px' } // ファーストビュー手前で先読み
    );

    io.observe(el);
    return () => io.disconnect();
  }, [page, hasMore, isLoading]);

  return (
    <ul>
      {pages.flat().map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
      {hasMore && <div ref={sentinelRef} aria-hidden="true" />}
      {isLoading && <p role="status">Loading...</p>}
      {!hasMore && <p>これ以上ありません</p>}
    </ul>
  );
}

rootMarginで先読みする理由

rootMargin: '200px'を指定すると、センチネルが実際に画面に入る200px手前で交差が発火します。 これによりユーザーが到達する前にフェッチを開始でき、スクロールが止まって白い空白を見せる時間を最小化できます。 一方で値を大きくしすぎると帯域の無駄遣いになるため、リストアイテム1〜2個分が目安です。

カスタムフックとして抽象化する

上の実装は読みやすい反面、ページ管理・ローディング状態・交差監視が一つのコンポーネントに同居していて再利用できません。 useInfiniteScrollのような汎用フックに切り出すと、ビューは「どこにセンチネルを置くか」だけ考えれば済みます。

import { useCallback, useEffect, useRef } from 'react';

type Options = {
  hasMore: boolean;
  isLoading: boolean;
  onLoadMore: () => void;
  rootMargin?: string;
};

export function useInfiniteScroll<T extends HTMLElement>({
  hasMore,
  isLoading,
  onLoadMore,
  rootMargin = '200px',
}: Options) {
  const observerRef = useRef<IntersectionObserver | null>(null);

  // 「callback ref」パターン: センチネル要素が変わるたびに observer を張り直す
  const sentinelRef = useCallback(
    (node: T | null) => {
      if (observerRef.current) observerRef.current.disconnect();
      if (!node || !hasMore) return;

      observerRef.current = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting && !isLoading) onLoadMore();
        },
        { rootMargin }
      );
      observerRef.current.observe(node);
    },
    [hasMore, isLoading, onLoadMore, rootMargin]
  );

  // アンマウント時のクリーンアップ
  useEffect(() => () => observerRef.current?.disconnect(), []);

  return sentinelRef;
}

使う側はこれだけです。関心が「データ取得」と「スクロール監視」に綺麗に分離されました。

function PostList() {
  const { items, isLoading, hasMore, loadMore } = usePosts(); // 別途実装
  const sentinelRef = useInfiniteScroll<HTMLDivElement>({
    hasMore,
    isLoading,
    onLoadMore: loadMore,
  });

  return (
    <ul>
      {items.map((p) => <li key={p.id}>{p.title}</li>)}
      {hasMore && <div ref={sentinelRef} aria-hidden="true" />}
    </ul>
  );
}

TanStack Query の useInfiniteQuery と組み合わせる

自前でpages配列を管理するのは意外と面倒です。 重複フェッチ防止・エラー時の再試行・画面離脱後のキャッシュ保持・ページ単位でのrefetch——これらはすべてTanStack QueryのuseInfiniteQueryが面倒を見てくれる領域です。

import { useInfiniteQuery } from '@tanstack/react-query';

type Page = { items: Post[]; nextCursor: string | null };

export function usePosts() {
  return useInfiniteQuery<Page, Error>({
    queryKey: ['posts'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/posts?cursor=${pageParam ?? ''}`);
      if (!res.ok) throw new Error('fetch failed');
      return res.json();
    },
    initialPageParam: null as string | null,
    getNextPageParam: (last) => last.nextCursor, // null なら hasNextPage=false
  });
}

ビュー側は、フックから返ってくるdata.pagesをflatしてレンダリングし、 前節のuseInfiniteScrollfetchNextPageを繋ぐだけです。

function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = usePosts();

  const sentinelRef = useInfiniteScroll<HTMLDivElement>({
    hasMore: hasNextPage,
    isLoading: isFetchingNextPage,
    onLoadMore: fetchNextPage,
  });

  if (status === 'pending') return <p>Loading...</p>;
  if (status === 'error') return <p>読み込みに失敗しました</p>;

  return (
    <ul>
      {data.pages.flatMap((p) => p.items).map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
      {hasNextPage && <div ref={sentinelRef} aria-hidden="true" />}
      {isFetchingNextPage && <p role="status">さらに読み込み中...</p>}
    </ul>
  );
}

カーソル型 vs オフセット型

APIがカーソル型(?cursor=xxx)かオフセット型(?page=2)かで、getNextPageParamの書き方が変わります。 データが頻繁に挿入・削除される画面ではカーソル型一択です。オフセット型だと、投稿が1件増えるたびにリスト全体がずれて「同じ投稿が2回出る/1件飛ばされる」バグが発生します。

方式 クエリ例 向く場面 弱点
オフセット ?page=2&size=20 更新頻度が低い管理画面・アーカイブ リストが動的に変化すると重複・欠落が発生
カーソル ?cursor=eyJpZCI6MTAwfQ SNSタイムライン・リアルタイムフィード 「ページ番号で飛ぶ」ができない
タイムスタンプ ?before=2026-04-01T00:00:00Z 時系列フィード タイムスタンプが重複すると不安定

実務で気をつけたい落とし穴

1. メモリとDOMノードの肥大化

無限スクロールの最大の敵はDOMノードの際限ない増加です。 数千件のアイテムがページに残り続けると、スクロールのFPSが落ち、メモリも圧迫します。 大規模リストではウィンドウイング(仮想スクロール)ライブラリ—— @tanstack/react-virtualreact-window——と組み合わせ、画面外の要素はDOMから外すのが定石です。

2. StrictModeでの二重マウント

React 18以降、開発モードの<StrictMode>ではuseEffectが2回走ります。 IntersectionObserverを張る副作用は必ずクリーンアップ関数でdisconnect()すること。 怠ると本番では問題なくても開発中に同じページを2回フェッチして混乱します。

3. 戻るボタンでスクロール位置が失われる

記事詳細ページから一覧に戻ったとき、スクロール位置とロード済みページ数の両方を復元しないとUXが破綻します。 TanStack QueryならstaleTimeを長めに設定すればキャッシュ自体は保持されますが、 スクロール位置は自前でsessionStorage等に保存し、requestAnimationFrameで再スクロールする必要があります。

4. 初期ページが短くてセンチネルが最初から画面内にある

初回レスポンスの件数が少なくセンチネルが即座に交差すると、連続で次ページを取りに行って止まらなくなることがあります。 isLoadingガードは入れていても、フェッチ完了後の再レンダリングで即座に再交差するためです。 対策は「1ページあたりの件数をビューポートより十分多く設定する」か、 「交差検知後に短いsetTimeoutでデバウンスする」かのいずれかです。

アクセシビリティとUX上の作法

無限スクロールはアクセシビリティ的に扱いが難しいパターンです。以下は最低限満たしたいポイント:

  • 読み込み状態をaria-liveで通知: ローディング表示にrole="status"aria-live="polite"を付け、スクリーンリーダーに新着を知らせる。
  • キーボードでも末尾に到達できる: Tab移動でセンチネル付近にフォーカスが進んだとき、交差が発火すること(IntersectionObserverはスクロール操作に依存しないので基本OK)。
  • フッターを諦めない: 無限スクロールのページでは、フッターを「リストの下」ではなく「サイドバー」や「ヘッダー直下」に逃がす。あるいは「もっと読み込む」ボタンで明示的な区切りを作る(Load Moreパターン)。
  • 件数の表示: 「現在Nページ目/これまでM件読み込み済み」を小さく出すと、ユーザーが現在位置を把握しやすい。
  • 端末のデータ節約モードを尊重: navigator.connection?.saveDataがtrueなら先読みrootMarginを0にする等の配慮。

採用判断のチェックリスト

最後に、無限スクロールを入れるかどうかの判断基準をまとめておきます。

  • コンテンツは連続して流し読みする性質か?(Yesなら向く)
  • ユーザーは「何件中の何番目」を知りたいか?(Yesならページネーション)
  • フッターに重要情報があるか?(Yesなら無限スクロールを避けるかLoad Moreに)
  • 個別アイテムへのディープリンクが必要か?(Yesなら詳細ページ分離+カーソル型API)
  • 総件数が数千を超えるか?(Yesなら仮想スクロールと併用必須)

「最新技術だから入れる」ではなく、「このコンテンツの消費のされ方に合うか」で判断するのが作法です。 実装自体はIntersectionObserver + useInfiniteQuery + 仮想スクロールの3点セットで、もはやテンプレート化された領域と言ってよいでしょう。 むしろ難しいのは、どこで止めてあげるかという設計判断の方です。

理解度チェック

問題 0 / 50%
Q1

モダンな無限スクロール実装では、リスト末尾に「___」と呼ばれる監視用の空要素を置き、それがビューポートに入ったかを検知する。