· yuta

IntersectionObserverで無限スクロール(無限ページネーション)を実装する

#react#typescript#frontend

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();
  }
});

一見できそうですが、問題があります:

  1. 毎回の計算コスト: スクロールイベントは頻繁に発火するため、その度に DOM 要素の位置を計算する必要があります(reflow)。
  2. 二重実行の防止: fetchNextPage() 中に再度条件を満たすと、もう一度呼び出されてしまう可能性があります。
  3. デバウンス/スロットルの実装: 上記を防ぐために、自分でデバウンス処理を書く必要があります。
  4. 複数の監視対象に対応しにくい: メインコンテンツ以外にも、サイドバーの広告などを監視したい場合、コードが複雑になります。

IntersectionObserver とは

IntersectionObserver は、ブラウザが提供する API で、「DOM要素が画面に見えているかどうか」を自動的に監視してくれます。

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log("見えた!");
      fetchNextPage();
    }
  });
});

observer.observe(loadMoreElement);

仕組みは単純です:

  1. IntersectionObserver を作成
  2. observer.observe() で監視対象を登録
  3. 要素が画面に見えた・消えたとき、コールバックが自動実行される

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);
  }
};

ポイント:

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 ですが、以下のブラウザで利用可能です:

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 では標準的な実装方式となっています。