第18章:ページング入門②(無限スクロールの考え方)♾️📱
この章は「次へボタンのページング」を、スマホっぽい 無限スクロール に進化させます😎✨ コツはシンプルで、Firestoreのカーソル(startAfter) と、IntersectionObserver(画面下に来たら読む) を合体させるだけです💪📜
0) この章でできるようになること 🎯
- 画面の一番下に近づいたら、次の10件を自動で読み込む♾️
- 二重読み込み(連打・多重発火) を防げる🧱
- 重複表示 / 取りこぼし を減らす考え方がわかる🧠
- AIで テストデータを秒速で増やして、無限スクロールを気持ちよく検証できる🤖✨ (Firebase)
1) まず“無限スクロールの正体”を理解しよう 👀♾️

無限スクロールって名前だけど、正体は ページング(ページを小分けに読む) です📜 違いは「次ページを取りに行くタイミング」が ボタン か スクロール か、だけ👍
Firestore側は前章と同じで、基本はこれ👇
orderBy(...)で順番を固定limit(10)で10件ずつ読む- 次ページは
startAfter(lastDoc)で 最後のドキュメントの次から読む (Firebase)
2) 設計:無限スクロールで死ぬポイント3つ 💥(先に潰す)
無限スクロールは実装自体は簡単なんだけど、事故るのはここ👇
- 多重発火で二重読み込み(一瞬で2回読んで重複する)😇
- 重複表示(同じdocが2回appendされる)👯
- 取りこぼし(順番がブレたり、lastDocの扱いが雑で抜ける)🫥
この章では、最低限のガードとして👇を入れます🧱✨
inFlightRef(通信中フラグ)で 同時実行を禁止seenIds(すでに表示したID)で 重複排除orderBy(createdAt desc)などで 順番を固定(順番が固定されないとページが壊れる) (Firebase)
3) 実装①:Firestore「1ページ取得」関数を作る 📦🔎

前章の「次へボタン」でも使える形にしておくと超ラクです😋
注意:
orderBy()したフィールドが存在しないドキュメントは、結果に出てきません(存在チェックも兼ねる仕様) なのでcreatedAtが無いデータが混じると「なんか消えた?」になります🫠 (Firebase)
import {
collection,
query,
orderBy,
limit,
startAfter,
getDocs,
type Firestore,
type QueryDocumentSnapshot,
type DocumentData,
} from "firebase/firestore";
export type Todo = {
id: string;
title: string;
done: boolean;
// createdAt / updatedAt は前章までで入れてる想定(serverTimestampでもOK)
createdAt?: unknown;
updatedAt?: unknown;
tags?: string[];
};
const PAGE_SIZE = 10;
export async function fetchTodosPage(
db: Firestore,
after?: QueryDocumentSnapshot<DocumentData>
): Promise<{
items: Todo[];
lastDoc?: QueryDocumentSnapshot<DocumentData>;
hasMore: boolean;
}> {
const baseQuery = query(
collection(db, "todos"),
orderBy("createdAt", "desc"),
limit(PAGE_SIZE)
);
const q = after ? query(baseQuery, startAfter(after)) : baseQuery;
const snap = await getDocs(q);
const items: Todo[] = snap.docs.map((d) => ({
id: d.id,
...(d.data() as Omit<Todo, "id">),
}));
const lastDoc = snap.docs.length ? snap.docs[snap.docs.length - 1] : undefined;
// 「同じ件数が取れた = まだ続きそう」という雑め推測(学習用には十分)
const hasMore = snap.docs.length === PAGE_SIZE;
return { items, lastDoc, hasMore };
}
Firestoreのカーソルは ドキュメントスナップショットをそのまま渡せる のが気楽で良いです👌 (Firebase)
4) 実装②:IntersectionObserverで「下に来たら loadMore」👀⬇️

IntersectionObserverは「ある要素が画面内に入ったら教えてくれる」APIです📡 無限スクロールでは、リストの最後に “見張り役のdiv(sentinel)” を置いて、見えたら次ページ読みに行きます♾️✨
rootMarginを広げると「ちょい手前で先読み」できて気持ちいいです🚀 (CSSのmarginっぽい文字列) (MDN Web Docs)
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Firestore, QueryDocumentSnapshot, DocumentData } from "firebase/firestore";
import { fetchTodosPage, type Todo } from "./fetchTodosPage";
export function useInfiniteTodos(db: Firestore) {
const [items, setItems] = useState<Todo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// 二重読み込みガード🧱
const inFlightRef = useRef(false);
// 次ページの起点(カーソル)📌
const lastDocRef = useRef<QueryDocumentSnapshot<DocumentData> | undefined>(undefined);
// 重複排除👯❌
const seenIdsRef = useRef<Set<string>>(new Set());
const loadMore = useCallback(async () => {
if (!hasMore) return;
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
try {
const { items: nextItems, lastDoc, hasMore: nextHasMore } = await fetchTodosPage(
db,
lastDocRef.current
);
// 重複排除してから足す
const filtered = nextItems.filter((t) => !seenIdsRef.current.has(t.id));
filtered.forEach((t) => seenIdsRef.current.add(t.id));
setItems((prev) => [...prev, ...filtered]);
lastDocRef.current = lastDoc;
setHasMore(Boolean(lastDoc) && nextHasMore);
} catch (e) {
const msg = e instanceof Error ? e.message : "読み込みに失敗しました";
setError(msg);
} finally {
setLoading(false);
inFlightRef.current = false;
}
}, [db, hasMore]);
// 初回ロード(1ページ目)
useEffect(() => {
// すでにitemsがあるなら不要(StrictModeで二重実行っぽく見える時の保険)
if (items.length > 0) return;
loadMore();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadMore]);
// IntersectionObserverで監視👀
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
if (!first) return;
if (first.isIntersecting) {
// ここでloadMoreするだけ♾️
loadMore();
}
},
{
root: null, // 画面(viewport)
rootMargin: "200px", // 少し手前で先読み🚀
threshold: 0,
}
);
observer.observe(el);
return () => observer.disconnect();
}, [loadMore]);
const reset = useCallback(() => {
setItems([]);
setHasMore(true);
setError(null);
setLoading(false);
inFlightRef.current = false;
lastDocRef.current = undefined;
seenIdsRef.current = new Set();
}, []);
return { items, loading, error, hasMore, loadMore, reset, sentinelRef };
}
5) 実装③:画面に組み込む(sentinelを一番下に置く)🧩🧱

import React from "react";
import { db } from "./firebase"; // 既存
import { useInfiniteTodos } from "./useInfiniteTodos";
export function TodoListInfinite() {
const { items, loading, error, hasMore, sentinelRef, loadMore } = useInfiniteTodos(db);
return (
<div style={{ maxWidth: 720, margin: "0 auto" }}>
<h2>ToDo(無限スクロール)♾️</h2>
{items.map((t) => (
<div key={t.id} style={{ padding: 12, borderBottom: "1px solid #eee" }}>
<div style={{ fontWeight: 700 }}>{t.title}</div>
<div style={{ opacity: 0.7 }}>{t.done ? "✅ done" : "⬜ todo"}</div>
</div>
))}
{/* 見張り役(ここが見えたら次を読む) */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loading && <p>読み込み中…⏳</p>}
{error && (
<div style={{ padding: 12 }}>
<p style={{ color: "crimson" }}>エラー:{error} 😵</p>
<button onClick={() => loadMore()}>もう一回読み込む🔁</button>
</div>
)}
{!hasMore && !loading && <p>ここまで!🎉</p>}
</div>
);
}
6) 事故回避メモ(ここ大事)🧠🧯

✅ 二重読み込み(多重発火)対策
inFlightRefで 通信中は無視🧱- IntersectionObserverは状況によっては何度も発火します(スクロールやレイアウト更新でも)😅
✅ 重複表示対策
seenIdsで IDが同じなら足さない👯❌ 「同じページを2回読んじゃった」事故が起きても、UI上は守れる✅
✅ 取りこぼし対策
orderBy(...)を固定しないと、ページングは壊れます💥- さらに
orderByに使うフィールドが欠けたドキュメントは除外されるので、データ側も整えるのが大事です🧹 (Firebase)
✅ コスト意識もちょいだけ💸
無限スクロールは「気持ちいい」反面、スクロールした分だけ読み取りが増えやすいです📈
使いすぎ防止は limit 小さめ・rootMargin 先読みしすぎない・hasMore をちゃんと止める、あたりが効きます👌(使用量の監視方法も公式にあります) (Firebase)
7) 🤖 AIで“テストデータ作り”を爆速にする(無限スクロールのために)

無限スクロールは データが少ないと気持ちよさが分からない んですよね😂 そこで、Firebaseの Firebase AI Logic を使って「ToDoタイトルを30個作れ!」→ Firestoreに流し込み、をやります✨ (Web向けのAI Logic SDKで、Gemini/Imagenに安全にアクセスできる系のやつです) (Firebase)
ちなみにAI Logicの「Get started」には、モデルの提供状況・注意事項(モデルの入れ替え予定など)も載るので、使う前にチラ見推奨です👀 (Firebase)
7-1) AIでToDoタイトル配列を作る(JSONで返させる)🧾🤖
import { getAI, getGenerativeModel, GoogleAIBackend } from "firebase/ai";
import type { FirebaseApp } from "firebase/app";
export function createTodoTitleGenerator(app: FirebaseApp) {
const ai = getAI(app, { backend: new GoogleAIBackend() });
const model = getGenerativeModel(ai, { model: "gemini-2.5-flash-lite" });
return async function generateTodoTitles(count: number): Promise<string[]> {
const prompt = [
`日本語のToDoタイトルを${count}個作ってください。`,
`出力はJSON配列だけにしてください(例:["牛乳を買う","掃除する"])。`,
`短く、重複なし。`
].join("\n");
const result = await model.generateContent(prompt);
const text = result.response.text();
// 返答が余計な説明付きになっても拾えるように、配列部分だけ抜く(学習用の簡易パース)
const json = text.match(/\[[\s\S]*\]/)?.[0];
if (!json) throw new Error("AIの返答がJSON配列じゃなかった…🥲");
const arr = JSON.parse(json);
if (!Array.isArray(arr)) throw new Error("JSONが配列じゃない…🥲");
return arr.map(String);
};
}
この firebase/ai 系のAPI(getAI, getGenerativeModel など)は、公式のAI Logic紹介でも出てきます📚 (Firebase)
7-2) 生成したタイトルをFirestoreに入れる(少量ずつでOK)🗃️➕
import { collection, doc, serverTimestamp, writeBatch, type Firestore } from "firebase/firestore";
export async function seedTodos(db: Firestore, titles: string[]) {
const batch = writeBatch(db);
for (const title of titles) {
const ref = doc(collection(db, "todos")); // 自動ID
batch.set(ref, {
title,
done: false,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
tags: [],
});
}
await batch.commit();
}
これで 30件くらい を一気に作って、無限スクロールが気持ちよく動くかチェックできます♾️🎉
8) 🧠 AIを“デバッグ相棒”にする(Antigravity / Gemini CLI)🛠️🤖
「二重読み込みがたまに起きる😫」みたいな“地味バグ”は、AIにログとコード見せると早いです🔥
- Google の Antigravity は、エージェントに計画→実装→調査までやらせやすい“Mission Control”系です🕹️ (Google Codelabs)
- Gemini CLI はターミナル上のオープンソースAIエージェントで、ReActループ+MCPで「調べる→直す→テスト整える」みたいな流れが得意です🧰 (Google Cloud Documentation)
そのままコピペで使える依頼テンプレ(おすすめ)📋✨
- 「
useInfiniteTodosを読んで、二重読み込みが起きるパターンを列挙して。発生条件と修正案を出して」🧯 - 「IntersectionObserverの発火が多い。
rootMarginとinFlightRef周りを改善して、差分パッチで提案して」🔧 - 「Firestoreのページングで、重複表示・取りこぼしが起きる原因を3つ挙げて、今のコードで何が起きうるか説明して」🧠
9) ミニ課題 🧩🎯(この章の“できた!”ライン)
✅ ミニ課題A:二重読み込み完全ガード🧱
- すでに入れた
inFlightRefに加えて、Observerが連続で発火しても1回しか読まないのを確認👀 (ChromeのDevToolsでネットワーク見ながらやると楽しい🕵️)
✅ ミニ課題B:重複ゼロの保証👯❌
seenIdsを使って、同じIDが混じっても表示が増えないことを確認👍
✅ ミニ課題C:AIで50件作って気持ちよくスクロール♾️🎉
- AI Logicでタイトル生成 → seed → 無限スクロールで「ちゃんと追加で読める」を体験✨ (Firebase)
10) チェックリスト ✅😄
startAfter(lastDoc)が「最後の次から読む」って説明できる 📜 (Firebase)orderByのフィールドが欠けると検索結果に出ないのがわかる 🫥 (Firebase)- IntersectionObserverで「下に来たら読む」を作れた 👀⬇️ (MDN Web Docs)
- 二重読み込み・重複表示を自分で潰せた 🧱✨
- AIでテストデータを作って検証できた 🤖🎯 (Firebase)
次の第19章は、ローカルで安全に壊して練習する Emulator 編です🧪🧯 無限スクロールは“本番DBでガチャガチャ試す”のが一番危ないので、そこで一気に安心感が上がります😎