無限スクロールが解く問題
無限スクロール(Infinite Scroll)は、ユーザーがリストの末尾に近づいたタイミングで次のデータを自動読み込みするUIパターンです。 Twitter/X のタイムライン、Instagramのフィード、商品一覧の「続きを表示」など、コンテンツが連続する画面で広く採用されています。
技術的な本質は「ページネーションの自動化」です。
APIは依然として?page=2や?cursor=abcのようにページ単位でデータを返しますが、
フロント側はユーザーの明示的なクリックを待たず、スクロール位置から推測して次ページを取りに行きます。
ページネーションとの比較
| 観点 | ページネーション | 無限スクロール |
|---|---|---|
| 操作コスト | クリックが必要 | スクロールのみで連続閲覧 |
| 現在位置の把握 | 「3/20ページ」等で明確 | 曖昧になりがち |
| フッター到達性 | 各ページで必ず到達可能 | 到達困難(遠ざかる) |
| ブラウザバック | URLで位置復元しやすい | 位置復元に一手間必要 |
| SEO・ディープリンク | 得意 | 苦手(工夫が必要) |
| 向くコンテンツ | 検索結果・管理画面・アーカイブ | SNS・レコメンド・探索型フィード |
実装方式の選択肢
React + TypeScriptで無限スクロールを実装するアプローチは、歴史的に以下の順で進化してきました。
- scrollイベント + スロットリング:
window.scrollYとdocument.body.scrollHeightを比較。愚直だがイベント頻度が高く、再計算コストとジャンク(カクつき)が出やすい。 - IntersectionObserver: リスト末尾にセンチネル(監視用の空要素)を置き、それが画面に入った瞬間だけコールバックが呼ばれる。ブラウザ側で効率的に監視されるためCPU負荷が低い。2020年代の事実上の標準。
- 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してレンダリングし、
前節のuseInfiniteScrollにfetchNextPageを繋ぐだけです。
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-virtualやreact-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点セットで、もはやテンプレート化された領域と言ってよいでしょう。
むしろ難しいのは、どこで止めてあげるかという設計判断の方です。
理解度チェック
モダンな無限スクロール実装では、リスト末尾に「___」と呼ばれる監視用の空要素を置き、それがビューポートに入ったかを検知する。