IntersectionObserverで無限スクロール(無限ページネーション)を実装する
Webアプリでメモやツイート、商品一覧などを表示するとき、全てを一度に読み込むのは効率が悪くなります。代わりに ページネーション(ページ分割)してデータを分割読み込みします。その中でも「ユーザーがスクロールで下部に近づいたら、自動的に次のページを読み込む」という 無限スクロール(Infinite Scroll) は、ユーザー体験が良くよく見かけます。
この仕組みを実現する最新のブラウザAPI が IntersectionObserver です。名前は難しそうですが、やっていることはシンプル。この記事では、課題から仕組み、実装パターンまでを整理しておきます。
そもそも何が課題なのか
ユーザーがスクロールして「次のデータを読み込む」という要素が画面に見えたら、自動的にデータ取得を開始する——これを従来のやり方で実装しようとすると、どうなるでしょうか。
window.addEventListener('scroll', () => {
const element = document.getElementById('load-more');
const rect = element.getBoundingClientRect();
// ユーザーがスクロールするたびに、毎回計算が走る
if (rect.top < window.innerHeight) {
console.log("ページの下部が見えた!");
fetchNextPage();
}
});
一見できそうですが、問題があります:
- 毎回の計算コスト: スクロールイベントは頻繁に発火するため、その度に DOM 要素の位置を計算する必要があります(reflow)。
- 二重実行の防止:
fetchNextPage()中に再度条件を満たすと、もう一度呼び出されてしまう可能性があります。 - デバウンス/スロットルの実装: 上記を防ぐために、自分でデバウンス処理を書く必要があります。
- 複数の監視対象に対応しにくい: メインコンテンツ以外にも、サイドバーの広告などを監視したい場合、コードが複雑になります。
IntersectionObserver とは
IntersectionObserver は、ブラウザが提供する API で、「DOM要素が画面に見えているかどうか」を自動的に監視してくれます。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log("見えた!");
fetchNextPage();
}
});
});
observer.observe(loadMoreElement);
仕組みは単純です:
IntersectionObserverを作成observer.observe()で監視対象を登録- 要素が画面に見えた・消えたとき、コールバックが自動実行される
IntersectionObserver の設計が優れている理由
1. ブラウザが最適化
scroll イベントのように毎フレーム計算するのではなく、ブラウザが効率的に監視してくれます。パフォーマンスが段違いです。
2. 複数要素への対応が簡単
// 複数の要素を同時に監視できる
observer.observe(要素A);
observer.observe(要素B);
observer.observe(要素C);
// コールバックで全ての情報が返ってくる
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.target === 要素A && entry.isIntersecting) {
console.log("要素A が見えた");
}
});
});
3. フレキシブルなオプション
const observer = new IntersectionObserver(callback, {
root: null, // viewport を基準にする
rootMargin: '100px', // 画面より100px手前で反応させる
threshold: [0.5, 1] // 50%以上見えた時、100%見えた時に反応
});
React での実装パターン
では、実際に React でどう実装するか。メモアプリを例にします。
1. 状態の準備
import { useState, useEffect, useRef } from 'react';
import type { Note } from '../../modules/notes/note.entity';
import { noteRepository } from '../../modules/notes/note.repository';
export default function Home() {
const [notes, setNotes] = useState<Note[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// ← ページ下部の「読み込みトリガー」要素への参照
const loadMoreRef = useRef<HTMLDivElement | null>(null);
各状態の役割:
| 状態 | 説明 |
|---|---|
notes |
読み込み済みのメモ一覧 |
page |
現在のページ番号(1, 2, 3, …) |
hasMore |
次のページがあるか(全て読み込み済みならfalse) |
isLoading |
現在読み込み中か(二重読み込み防止用) |
loadMoreRef |
ページ下部の要素への参照 |
2. データ取得関数
const fetchNotes = async () => {
if (isLoading || !hasMore) return; // 読み込み中か終了済みなら何もしない
setIsLoading(true);
try {
const limit = 12; // 1ページあたりのデータ数
const response = await noteRepository.getNotes(page, limit);
if (page === 1) {
// 最初のページ → 置き換え
setNotes(response.notes);
} else {
// 2ページ目以降 → 追加
setNotes((prev) => [...prev, ...response.notes]);
}
// ページネーション情報を更新
setPage(page + 1);
setHasMore(response.pagination.page < response.pagination.totalPages);
} catch (error) {
console.error(error);
addFlashMessage("メモの取得に失敗しました。", "error");
} finally {
setIsLoading(false);
}
};
ポイント:
- 1ページ目は
setNotes()で置き換え(上書き) - 2ページ目以降は
setNotes((prev) => [...prev, ...])で追加 - 常に読み込み状態を解除(
finallyブロック)
3. IntersectionObserver のセットアップ
これが本体です:
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
const target = entries[0];
// 要素が見える && 次ページがある && 読み込み中じゃない
if (target.isIntersecting && hasMore && !isLoading) {
fetchNotes();
}
});
// 監視対象の要素を登録
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
// クリーンアップ: コンポーネント削除時に監視を停止
return () => {
observer.disconnect();
};
}, [hasMore, isLoading]); // hasMore や isLoading が変わったら再実行
流れを図示すると:
1. useEffect が実行
↓
2. IntersectionObserver を作成
↓
3. loadMoreRef.current(ページ下部の <div>)を監視開始
↓
4. ユーザーがスクロール
↓
5. <div ref={loadMoreRef}> が画面に見える
↓
6. IntersectionObserver が自動実行
→ target.isIntersecting = true
↓
7. 条件チェック(hasMore && !isLoading)
↓
8. 条件 OK → fetchNotes() を呼ぶ
↓
9. API で次のページのメモを取得
↓
10. ストアが更新される
↓
11. 画面に新しいメモが追加される
↓
12. さらにスクロール → 5に戻る(ループ)
4. JSX での配置
return (
<div className="home">
{/* メモ一覧 */}
<div className="notes-grid">
{notes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
{/* 【重要】ここが監視対象! */}
{/* スクロールでこの要素が見えたら、自動的に次を読み込む */}
<div
ref={loadMoreRef}
style={{ height: '20px' }}
// ← 読み込み中の場合はスピナーを表示することもできる
/>
</div>
);
なぜ target に参照が入っているのか
コード内に entries[0] や entry.target が出てきますが、「なぜ target に要素が入っているのか」を理解しておくと、複数要素を監視する場合に役立ちます。
理由:IntersectionObserver は複数要素を監視できるから
const observer = new IntersectionObserver((entries) => {
// entries は配列
// [
// { target: <div id="load-more">, isIntersecting: true },
// { target: <div id="ads">, isIntersecting: false }
// ]
entries.forEach((entry) => {
if (entry.target.id === "load-more" && entry.isIntersecting) {
console.log("メモの読み込みトリガーが見えた");
} else if (entry.target.id === "ads" && entry.isIntersecting) {
console.log("広告が見えた");
}
});
});
observer.observe(loadMoreElement);
observer.observe(adsElement);
通常は1つの要素だけを監視することが多いので、entries[0] で十分です。
依存関係配列の重要性
useEffect(() => {
// ...
}, [hasMore, isLoading]);
この配列が重要です。hasMore や isLoading が変わったら、useEffect 全体を再実行するという指示です。
例:最初
hasMore = true, isLoading = false
→ IntersectionObserver セットアップ
途中:メモ読み込み中
isLoading = true に変わる
→ useEffect 再実行(監視を再設定)
完了:全メモ読み込み済み
hasMore = false に変わる
→ useEffect 再実行(監視をクリーンアップ)
もしこの配列を [] にしてしまうと、初回マウント時だけセットアップして、その後は hasMore や isLoading が変わっても監視が更新されません。バグの原因になります。
実装時の注意点
1. 二重読み込み防止
// ✅ 正しい
if (target.isIntersecting && hasMore && !isLoading) {
fetchNotes();
}
// ❌ 間違い: isLoading チェックがない
if (target.isIntersecting && hasMore) {
fetchNotes(); // 読み込み中に何度も呼ばれる
}
2. メモリリーク防止
// ✅ クリーンアップ必須
return () => {
observer.disconnect(); // これがないとメモリリーク
};
3. rootMargin で早期読み込み
データベースへのクエリが遅い場合、ユーザーが下部に到達する前に読み込みを開始すると UX が向上します:
const observer = new IntersectionObserver(callback, {
rootMargin: '200px' // 画面より200px手前で反応
});
4. threshold で読み込みタイミングを調整
const observer = new IntersectionObserver(callback, {
threshold: 0.5 // 要素の50%以上が見えたら反応
});
API エンドポイント設計
無限スクロールを実装するには、バックエンド API もページネーションに対応している必要があります:
// フロント側
const response = await noteRepository.getNotes(page, limit);
// API レスポンス例
{
notes: [...],
pagination: {
page: 2, // 現在のページ
limit: 12, // 1ページあたりのデータ数
total: 50, // 全データ数
totalPages: 5 // 全ページ数
}
}
ブラウザ互換性
IntersectionObserver は比較的新しい API ですが、以下のブラウザで利用可能です:
- ✅ Chrome 51+
- ✅ Firefox 55+
- ✅ Safari 12.1+
- ✅ Edge 16+
- ❌ IE 11(ポリフィル必要)
IE 11 対応が必要な場合は、intersection-observer というポリフィルライブラリを使用します:
npm install intersection-observer
import 'intersection-observer'; // IE 11対応
React Query / SWR との組み合わせ
複雑なデータ取得ロジックには、データ取得ライブラリとの組み合わせが便利です:
import { useInfiniteQuery } from '@tanstack/react-query';
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
queryKey: ['notes'],
queryFn: ({ pageParam = 1 }) => noteRepository.getNotes(pageParam, 12),
getNextPageParam: (lastPage) =>
lastPage.pagination.page < lastPage.pagination.totalPages
? lastPage.pagination.page + 1
: undefined,
});
useEffect(() => {
if (!loadMoreRef.current) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetching) {
fetchNextPage();
}
});
observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [hasNextPage, isFetching, fetchNextPage]);
React Query が状態管理を担当するため、コンポーネント内のロジックがシンプルになります。
まとめ
無限スクロール実装のポイント:
| ポイント | 説明 |
|---|---|
| IntersectionObserver | 要素が見えているかを自動監視するブラウザ API |
| 参照(ref) | JSX の要素を JavaScript から参照するための仕組み |
| 依存関係配列 | hasMore や isLoading の変更を detect して再実行 |
| 二重読み込み防止 | isLoading チェックと hasMore チェックの両方が重要 |
| クリーンアップ | observer.disconnect() でメモリリーク防止 |
| ページ分割 | 1ページ目は置き換え、2ページ目以降は追加 |
従来の scroll イベント方式と比較して、IntersectionObserver はブラウザが最適化してくれるため、パフォーマンスと保守性の両面で優れています。複数の監視対象に対応するのも容易なので、モダン React では標準的な実装方式となっています。