無限スクロールの難所はsentinelと仮想化:重複ロードと位置復元の対策
無限スクロールが本番で壊れる原因はsentinelの置き方と長大リストのDOM。IntersectionObserverの重複ロード防止、react-windowでの仮想化、戻る位置復元、SEO併用までReactで解説。
「最後の要素が見えたらfetch」。記事一覧の無限スクロールを、僕は最初その一文でClaude Codeに頼みました。デモは完璧に動いた。スクロールするとどんどん記事が出てくる。気持ちいい。
本番に出して3日後、Slackに「同じ記事が2回出る」「2000件くらいスクロールするとスマホが固まる」と報告が届きました。開いてみたら、ネットワークタブには2ページ目を取りに行くリクエストが3本並んでいて、DOMには記事カードが1800枚積み上がっていました。
無限スクロールの本当の難所は「見えたら読む」のところじゃないんです。見張り役(sentinel)をどこに置くか、読み込みが重複しないか、増え続けるDOMをどう間引くか。ここを外すと、デモで動いても本番で必ず詰まります。今日はこの3つに絞って、コピペで動くコードと一緒に解説します。
この記事の要点
- 無限スクロールが壊れる場所は3つ。sentinel(見張り要素)の位置、重複ロード/競合、増え続けるDOM。ここだけ先に潰す。
- sentinelは「リストの最後の子」ではなくリスト末尾の少し手前に独立配置し、
rootMarginで先読みする。これだけで「読み込みが間に合わない」が減る。 - 重複ロードは
useStateでは止まらない。useRefの即時ロック+古い通信を切るAbortControllerで防ぐ。 - 数百件を超えたら仮想化(react-window)でDOMを画面分だけに間引く。無限スクロールと仮想化は別レイヤーで、両方いる。
- 「戻る」での位置復元はブラウザ任せにせず、
scrollRestorationを切って自前で保存・復元する。SEOは通常のページ番号URLを残して両取りする。
まず用語を3つだけ
専門用語を最小限そろえます。これ以上は出てきません。
| 用語 | やさしい言い換え | 役割 |
|---|---|---|
| IntersectionObserver | 「要素が画面に入った?」を非同期で教えてくれる係 | スクロールイベントより軽く交差を監視 |
| sentinel | 見張り要素(番兵) | これが見えたら「次を読む合図」 |
| 仮想化(virtualization) | 見えてる分だけ描く間引き | DOMの枚数を一定に保つ |
IntersectionObserverは、監視したい要素がviewport(画面の見える範囲)に交差したかをブラウザが教えてくれるAPIです。スクロールのたびに座標を計算する昔のやり方より軽い。挙動の正確な定義はMDNのIntersection Observer APIにあります。
sentinelは、リストの末尾あたりに置く「合図用の小さな空要素」です。番兵と訳されることもありますが、見張り役と思えば十分。これが画面に入ったら次のページを読みます。
仮想化は、1000件あっても画面に映る20件ぶんしかDOMを作らない手法です。無限スクロールが「いつ次を読むか」の話なのに対し、仮想化は「読んだものをどう描くか」の話。レイヤーが違うので、長いリストでは両方必要になります。
難所1:sentinelの置き方で先読みが決まる
最初にやりがちな失敗が、リストの最後のカードそのものをObserverで監視することです。
これだと、最後のカードが画面に入った瞬間に読み込みが始まります。つまり「もう底に着いてから」走り出す。通信が1秒かかれば、ユーザーは1秒間まっさらな底を見つめることになります。スクロールの速い人ほど、この空白に出くわします。
対策は2つ。
- 監視対象をカードと分ける。リストの末尾に、高さほぼゼロの専用sentinel要素を1つ置く。カードを消したり並び替えたりしても、見張り役は影響を受けません。
rootMarginで底の手前を「見えた」ことにする。rootMargin: "600px 0px"と書くと、viewportの下端を仮想的に600px下へ広げて交差判定します。底の600px手前で先読みが走るので、ユーザーが底に着く頃には次のカードが並んでいます。rootMarginがrootの判定領域を広げる挙動はMDNのrootMarginで定義されています。
数字はリストの行の高さで調整します。1行が高いカード型なら600〜800px、低い行なら300pxくらいが目安。「先読みしすぎてデータ転送が無駄」と「ギリギリで空白が見える」のあいだを取ります。
難所2:重複ロードと競合を止める
冒頭の「同じ記事が2回出る」「リクエストが3本並ぶ」は、ほぼ全部この問題です。原因は2つあります。
ひとつは、ロード中フラグをuseStateで管理してしまうこと。Stateの更新は次のレンダーまで反映されません。Observerが短時間に2回発火すると、1回目のsetLoading(true)が反映される前に2回目が走り、両方とも「いまロードしてない」と判断して同時にfetchします。useStateは描画用、ロックには向きません。
解決はuseRefです。ref.current = trueは同期で即座に反映されるので、2回目の発火はその場で弾けます。
もうひとつは、古い通信が後から到着して新しい結果を上書きすること(レースコンディション)。素早くスクロールして2ページ目と3ページ目をほぼ同時に投げると、遅れて返った2ページ目が3ページ目を踏みつぶす、という事故が起きます。AbortControllerで前のリクエストを切ってから次を投げると防げます。
この2つを組み込んだフックが次です。カーソルAPI(最後に読んだ位置を渡す方式。番号送りとの使い分けはページネーション実装の記事が詳しい)を前提に、loadingRefの即時ロックとAbortControllerを入れてあります。
import { useCallback, useEffect, useRef, useState } from "react";
export type CursorPage<T> = {
items: T[];
nextCursor: string | null;
};
type FetchPage<T> = (args: {
cursor: string | null;
signal: AbortSignal;
}) => Promise<CursorPage<T>>;
type InfiniteStatus = "idle" | "loading" | "error" | "done";
type UseInfiniteCursorOptions<T> = {
fetchPage: FetchPage<T>;
mergeItems?: (previous: T[], next: T[]) => T[];
initialCursor?: string | null;
};
export function useInfiniteCursor<T>({
fetchPage,
mergeItems,
initialCursor = null,
}: UseInfiniteCursorOptions<T>) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [status, setStatus] = useState<InfiniteStatus>("idle");
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
// ロード中フラグはstateではなくrefで持つ。同期で即反映され、二重発火を即座に弾ける
const loadingRef = useRef(false);
const hasMore = cursor !== null || items.length === 0;
const loadMore = useCallback(async () => {
// 門番1:すでにロード中、またはもう次がないなら何もしない
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
// 門番2:前の通信が残っていたら切る。古いレスポンスの上書き事故を防ぐ
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
setError(null);
try {
const page = await fetchPage({ cursor, signal: controller.signal });
setItems((previous) =>
// 重複id除去のマージ関数があれば使う。なければ単純連結
mergeItems ? mergeItems(previous, page.items) : [...previous, ...page.items],
);
setCursor(page.nextCursor);
setStatus(page.nextCursor ? "idle" : "done");
} catch (unknownError) {
// abort由来のエラーは「失敗」ではないので無視する
if (unknownError instanceof DOMException && unknownError.name === "AbortError") {
return;
}
setError(unknownError instanceof Error ? unknownError : new Error("Load failed"));
setStatus("error");
} finally {
loadingRef.current = false;
}
}, [cursor, fetchPage, hasMore, mergeItems]);
// sentinel(見張り要素)のref。カードとは別の独立要素に付ける
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
observerRef.current?.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) void loadMore();
},
// 底の600px手前で先読み。空白を見せない
{ rootMargin: "600px 0px", threshold: 0 },
);
observerRef.current.observe(node);
},
[hasMore, loadMore],
);
useEffect(() => {
void loadMore();
// cleanupで通信とobserverを必ず止める。これを忘れるとメモリリークと古い結果の混入が起きる
return () => {
abortRef.current?.abort();
observerRef.current?.disconnect();
};
}, [loadMore]);
return { items, status, error, hasMore, loadMore, sentinelRef };
}
useEffectのcleanupでObserverとfetchを止める設計は、React公式のuseEffectリファレンスが基準です。mergeItemsに「同じidは入れない」関数を渡せば、万一サーバーが境界の1件を重複返ししても表示は二重になりません。重複ロードを「入口(ロック)」と「出口(マージで除去)」の二重で防ぐのがコツです。
難所3:エラー時のリトライ導線
無限スクロールは黙って失敗すると最悪です。底でくるくる回り続けるか、何も起きずに止まる。ユーザーは「壊れてる」と判断して離脱します。
設計の基本は、自動と手動を同じloadMoreでまかなうことです。Observerが呼ぶのも、ボタンが呼ぶのも同じ関数。失敗したらstatusを"error"にして、ユーザーが押せる「再試行」を出します。一覧コンポーネント側はこうなります。
import { useCallback } from "react";
import { useInfiniteCursor, type CursorPage } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
// 既に表示済みのidは弾く。境界の重複返しに備える保険
function mergeUniqueById(previous: Article[], next: Article[]) {
const seen = new Set(previous.map((item) => item.id));
return [...previous, ...next.filter((item) => !seen.has(item.id))];
}
async function fetchArticlePage({
cursor,
signal,
}: {
cursor: string | null;
signal: AbortSignal;
}): Promise<CursorPage<Article>> {
const params = new URLSearchParams({ limit: "20" });
if (cursor) params.set("cursor", cursor);
const response = await fetch(`/api/articles?${params}`, { signal });
if (!response.ok) throw new Error(`記事の取得に失敗 (${response.status})`);
return response.json();
}
export function ArticleFeed() {
const fetchPage = useCallback(fetchArticlePage, []);
const { items, status, error, hasMore, loadMore, sentinelRef } = useInfiniteCursor({
fetchPage,
mergeItems: mergeUniqueById,
});
return (
<section aria-labelledby="article-feed-title">
<h2 id="article-feed-title">最新の記事</h2>
{/* role="feed"で「流れていく一覧」だと読み上げに伝える */}
<div role="feed" aria-busy={status === "loading"}>
{items.map((article, index) => (
<article
key={article.id}
aria-posinset={index + 1}
aria-setsize={hasMore ? -1 : items.length}
>
<a href={article.href}>
<h3>{article.title}</h3>
</a>
<p>{article.summary}</p>
<time dateTime={article.publishedAt}>
{new Intl.DateTimeFormat("ja-JP").format(new Date(article.publishedAt))}
</time>
</article>
))}
</div>
{/* 失敗したら同じloadMoreを呼ぶ再試行ボタンを出す */}
{status === "error" && (
<div role="alert">
<p>読み込みに失敗しました。{error?.message}</p>
<button type="button" onClick={() => void loadMore()}>
再試行
</button>
</div>
)}
{/* sentinelはカードと独立した空要素。ここを見張る */}
<div ref={sentinelRef} aria-hidden="true" />
{/* 状態を読み上げに伝える。視覚的に見えなくても伝わる */}
<p aria-live="polite">
{status === "loading" && "読み込み中です。"}
{status === "done" && "すべての記事を表示しました。"}
</p>
</section>
);
}
role="feed"やaria-busyを使うならWAI-ARIAのfeedパターンも見ておくと、読み上げ順や現在位置の扱いが整います。回線が不安定な環境では、自動リトライを「3回まで・指数バックオフ(待ち時間を倍々に伸ばす)」にしておくと、一時的な失敗を勝手に回復してくれます。
難所4:長大リストはreact-windowで間引く
ここまでで「同じ記事が2回出る」は消えます。でも「2000件で固まる」はまだ残っています。理由は、無限スクロールがDOMを足し続けるからです。1件1ノードなら、2000件で2000枚のカードが常にメモリに乗る。スクロールのたびにブラウザは全部のレイアウトを計算し直し、やがてカクつきます。
ここで仮想化の出番です。画面に映る20件ぶんだけDOMを作り、外に出たものは捨てる。スクロール位置に応じて中身を入れ替えるので、何万件あってもDOMは一定枚数に保たれます。自前で書くのは大変なのでreact-windowを使います。
注意点が1つ。react-windowはv2でAPIが変わりました。以前はFixedSizeListをimportしてitemCount/itemSizeを渡していましたが、現在のreact-window v2はListをimportしてrowComponent/rowCount/rowHeightを渡します。検索で出てくる古い記事はFixedSizeListのままなので、コピペ前にバージョンを確認してください。
import { List, type RowComponentProps } from "react-window";
import { useInfiniteCursor } from "./useInfiniteCursor";
type Article = {
id: string;
title: string;
summary: string;
href: string;
publishedAt: string;
};
// 1行ぶんを描くコンポーネント。indexで何件目かが渡る
function ArticleRow({
index,
style,
items,
hasMore,
loadMore,
}: RowComponentProps<{
items: Article[];
hasMore: boolean;
loadMore: () => void;
}>) {
// 末尾を描くタイミング = 底に近づいた合図。ここで次ページを読む
// 仮想化ではsentinel要素が常時DOMにいないので、行の描画自体をトリガーにする
if (index >= items.length) {
if (hasMore) loadMore();
return <div style={style}>読み込み中…</div>;
}
const article = items[index];
return (
<div style={style}>
<a href={article.href}>{article.title}</a>
</div>
);
}
export function VirtualizedArticleFeed() {
const { items, hasMore, loadMore } = useInfiniteCursor<Article>({
fetchPage: async ({ cursor, signal }) => {
const params = new URLSearchParams({ limit: "50" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/articles?${params}`, { signal });
if (!res.ok) throw new Error(`記事の取得に失敗 (${res.status})`);
return res.json();
},
});
// 末尾に1行ぶん余白を足し、その行が描かれたら次を読む仕掛けにする
const rowCount = hasMore ? items.length + 1 : items.length;
return (
<List
rowComponent={ArticleRow}
rowCount={rowCount}
rowHeight={88}
rowProps={{ items, hasMore, loadMore }}
style={{ height: 600 }}
/>
);
}
ポイントは、仮想化すると常設のsentinel要素が使えなくなることです。画面外の行はDOMから消えるので、見張り役も一緒に消えてしまう。だから無限スクロールのトリガーを「行が描画されたこと」に切り替えています。rowCountを実データ+1にして、その最後の行が描かれたらloadMoreを呼ぶ。これが仮想化と無限スクロールを噛み合わせる定番の形です。可変高さの行や横スクロールなど、仮想化そのものを深掘りしたいときはReact仮想スクロールの実装記事を参照してください。
仮想化を入れる目安は、ざっくり数百件です。数十件なら素のDOMで十分で、仮想化はかえって複雑さを増やします。「カクつき始めたら導入」で間に合います。
難所5:「戻る」での位置復元とSEO
最後がいちばん見落とされる落とし穴です。記事詳細へ飛んで戻ると、一覧が先頭にリセットされて、さっき読んでいた場所が消える。これは離脱に直結します。
ブラウザの自動スクロール復元(history.scrollRestoration)は、無限スクロールでは効きません。戻った瞬間にはまだ1ページ目しか読み込まれておらず、復元したい位置のDOMがまだ存在しないからです。だから自前でやります。離脱前にスクロール位置と読み込み済みのカーソルを保存し、戻ってきたら復元する。
import { useEffect } from "react";
// 一覧コンポーネントの中で呼ぶ。keyはページごとに変える
export function useScrollRestore(key: string) {
useEffect(() => {
// ブラウザ任せの自動復元は切る。無限スクロールでは噛み合わないため
if ("scrollRestoration" in history) {
history.scrollRestoration = "manual";
}
// 戻ってきたとき:保存しておいた位置へ復元
const saved = sessionStorage.getItem(`scroll:${key}`);
if (saved) {
// DOMが積まれるのを1フレーム待ってから戻す
requestAnimationFrame(() => window.scrollTo(0, Number(saved)));
}
// 離脱前:いまのスクロール位置を保存
const save = () => sessionStorage.setItem(`scroll:${key}`, String(window.scrollY));
window.addEventListener("pagehide", save);
return () => window.removeEventListener("pagehide", save);
}, [key]);
}
ここで効くのがカーソルです。位置だけ戻しても、戻したい場所のカードがまだ読み込まれていなければ意味がない。読み込み済みの最後のカーソルもsessionStorageに残し、復元時はそこから一気に読み直す設計にすると、位置とデータの両方がそろいます。
そしてSEO。検索エンジンやSNSのカードは、ユーザーがスクロールした後の状態を見てくれません。無限スクロールしか入口がないと、奥の記事はクロールされにくくなります。対策は併用です。**通常のページ番号付きURL(/articles?page=2など)とサイトマップを必ず残し、無限スクロールはあくまで体験の上乗せにする。**番号送りURLの設計はページネーション実装の記事に寄せ、無限スクロール側はリンクを潰さないことだけ守れば両取りできます。表示速度そのものが気になってきたらパフォーマンス最適化の記事で計測から入るのが確実です。
Claude Codeに依頼するときの要件文
ここまでの難所を、最初の依頼にそのまま盛り込みます。エージェントは最初の要件で品質がほぼ決まります。Claude Codeの公式ワークフローでも、制約と検証観点を先に渡す流れが推奨されています。
ReactとNext.jsで記事一覧の無限スクロールを実装してください。
- IntersectionObserverのsentinelはカードと独立した要素にし、rootMarginで先読み
- 重複ロード防止はuseRefの即時ロック、古い通信はAbortControllerで中断
- エラー時は同じloadMoreを呼ぶ再試行ボタンを出す
- 数百件で仮想化(react-window v2のList)に切り替えられる構成にする
- 戻る位置復元はscrollRestorationをmanualにして自前で保存・復元
- SEO用に通常のページ番号URLとサイトマップは残す
完成後、二重fetch・observerのcleanup漏れ・AbortErrorの扱い・
仮想化時のトリガー・位置復元の観点で自己レビューしてください。
「実装して」で終わらせず、レビュー観点まで渡すのがコツです。Claude Code全体の概要はAnthropicのClaude Code overviewにあります。
よくある質問
Q. スクロールイベントとIntersectionObserver、どちらを使うべき? A. 新規ならIntersectionObserver一択です。スクロールイベントは発火が高頻度で、毎回座標計算が走ってカクつきやすい。Observerは交差したときだけ非同期で呼ばれるので軽く、sentinelとの相性もいいです。
Q. sentinelはリストの最後のカードに付けてはいけない?
A. 付けないほうが安全です。最後のカードを監視すると、底に着いてから読み込みが始まり空白が見えます。独立した空要素を末尾に置き、rootMarginで手前から先読みすると体験が安定します。
Q. useRefで十分ならAbortControllerはいらない?
A. 役割が違うので両方いります。useRefは「同時に2回走らせない」入口の門番、AbortControllerは「速いスクロールで古い結果が新しい結果を上書きしない」出口の門番です。片方だけだと、もう片方の事故が残ります。
Q. 仮想化は何件くらいから入れるべき? A. 目安は数百件です。数十件なら素のDOMで問題なく、仮想化はむしろ複雑さが勝ちます。スクロールでカクつきが体感できたら導入、で間に合います。
Q. 無限スクロールにするとSEOで不利? A. 無限スクロールだけにすると奥の記事がクロールされにくくなります。通常のページ番号URLとサイトマップを併用し、リンクを残せば不利になりません。無限スクロールは体験の上乗せと割り切るのが安全です。
実際に試した結果
冒頭の「同じ記事が2回」「2000件で固まる」は、結局この記事の5つを順に潰したら全部消えました。効果が大きかったのは2つです。
ひとつはloadingRefへの切り替え。useStateでロックしていたのをuseRefの即時ロックに変えただけで、並んでいた重複リクエストがその日のうちにゼロになりました。Stateは描画用、ロックには使わない——この一線を引いただけです。
もうひとつは仮想化。react-window v2のListに載せ替えたら、2000件でも常にDOMは20数枚で、スクロールがぬるっと滑るようになりました。最初は「無限スクロールに仮想化も足すの?」と面倒に感じたんですが、別レイヤーの別問題だと分かってからは迷いがなくなりました。
無限スクロールは「見えたら読む」の一行で動いてしまうぶん、難所が後から牙をむきます。sentinelの位置、重複の二重ガード、仮想化、位置復元、SEO併用。この5つを最初の要件に入れておけば、デモで動いたものがそのまま本番で耐えます。手を動かす前に、まずClaude Code研修のように観点をチームの定型として残しておくと、次の人が同じ穴に落ちません。学習用の教材は教材一覧にまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。