はじめに
「リストの一番下までスクロールしたら、次のデータを読み込む」いわゆる無限スクロールを実装するとき、IntersectionObserverというブラウザAPIが役立ちます。
業務で実際にこのAPIを使って無限スクロールを実装したので、Reactで無限スクロールを実現するための方法をメモがわりにまとめます。
対象読者
-
IntersectionObserverを使ったことがない方 - 無限スクロールをライブラリなしで実装したい方
環境
- React 18以降
- TypeScript
IntersectionObserverとは
ある要素が画面に入ったかどうかを監視するブラウザAPIです。
素のJavaScriptで書くとこうなります。
// 1. 「要素が画面に入ったら何をする?」を定義
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) {
console.log('画面に入った!');
}
});
// 2. 監視を開始
const target = document.getElementById('sentinel');
observer.observe(target);
// 3. 不要になったら解除
observer.disconnect();
これだけです。observeで監視を始め、要素が画面に入るとisIntersectingがtrueになってコールバックが呼ばれます。
rootMarginで「手前」に発火させる
オプションのrootMarginを使うと、要素が画面に入る手前でコールバックを発火できます。
const observer = new IntersectionObserver(callback, {
rootMargin: '200px', // 画面の200px手前で発火
});
無限スクロールではこれが重要です。ユーザーがリスト末尾に到達する前にデータの読み込みを開始できるので、待ち時間が減ります。
Reactで使う:useIntersectionフック
ReactではuseEffectの中でobserveし、クリーンアップでdisconnectするのが基本です。これをカスタムフックにまとめます。
import { useEffect, type RefObject } from 'react';
export const useIntersection = (
ref: RefObject<HTMLElement | null>, // 監視したいDOM要素への参照
onIntersect: () => void, // 画面に入ったら呼ぶ関数
{ enabled = true, rootMargin = '0px' }: { enabled?: boolean; rootMargin?: string } = {},
) => {
useEffect(() => {
// ① enabledがfalseなら何もしない(読み込み中などに監視を止める)
if (!enabled) return;
// ② refから実際のDOM要素を取り出す
const target = ref.current;
if (!target) return;
// ③ IntersectionObserverを作る(ここではまだ監視は始まっていない)
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
onIntersect(); // ⑤ 画面に入った!→ コールバック実行
}
},
{ rootMargin },
);
// ④ 監視を開始する(ここで初めてブラウザが要素を見張り始める)
observer.observe(target);
// ⑥ クリーンアップ:コンポーネントが消えた or 依存が変わった時に監視を解除
return () => observer.disconnect();
}, [ref, onIntersect, enabled, rootMargin]);
};
フックの中で何が起きているか
時系列で追うとこうなります。
コンポーネントがマウントされる
→ useEffectが実行される
→ ①② チェックを通過
→ ③④ Observerを作って監視開始
→(ブラウザがバックグラウンドで要素を見張っている)
ユーザーがスクロールしてsentinelが画面に近づく
→ ⑤ ブラウザが「画面に入ったよ」とコールバックを呼ぶ
→ onIntersect()が実行される(= loadMore)
enabledがfalseに変わる(読み込み中)
→ useEffectの依存が変わったので、まず⑥のクリーンアップが走る(監視解除)
→ useEffectが再実行されるが、①でreturnして何もしない
enabledがtrueに戻る(読み込み完了)
→ useEffectが再実行 → ③④で新しいObserverを作って監視再開
つまりenabledが変わるたびにObserverを作り直すことで、監視のON/OFFを制御しています。
⑥のreturn () => observer.disconnect()を忘れると、コンポーネントがアンマウントされた後もオブザーバーが動き続けてメモリリークになります。
無限スクロールコンポーネントを作る
useIntersectionを使って、リスト末尾に置くだけで動く無限スクロールコンポーネントを作ります。
'use client';
import { useIntersection } from '@/hooks/useIntersection';
import { useRef } from 'react';
export function InfiniteScroll({
hasMore,
isLoading,
onLoadMore,
rootMargin = '200px',
}: {
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
rootMargin?: string;
}) {
const sentinelRef = useRef<HTMLDivElement>(null);
useIntersection(sentinelRef, onLoadMore, {
enabled: hasMore && !isLoading,
rootMargin,
});
return (
<div ref={sentinelRef} style={{ minHeight: 1 }} aria-hidden>
{isLoading && <p>読み込み中...</p>}
</div>
);
}
minHeight: 1を指定しているのは、高さ0の要素はIntersectionObserverに検知されないことがあるためです。面積がないと「ビューポートと交差した」と判定されず、コールバックが呼ばれません。最低1pxの高さを確保することで確実に検知されるようにしています。
仕組み
┌───────────────────┐
│ アイテム 1 │
│ アイテム 2 │
│ アイテム 3 │
│ ... │
└───────────────────┘
← rootMargin: 200px(ここに来たら発火)
┌───────────────────┐
│ sentinel(1px) │ ← IntersectionObserverが監視
└───────────────────┘
- リスト末尾にsentinel(番兵)要素を置く
- sentinelが画面に近づくと
onLoadMoreが呼ばれる - 読み込み中は
enabled: falseで監視を一時停止 - 読み込み完了で監視再開 → 繰り返し
使う側
'use client';
import { useCallback, useState } from 'react';
import { InfiniteScroll } from '@/components/InfiniteScroll';
export default function ItemList() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const loadMore = useCallback(async () => {
setIsLoading(true);
try {
const lastItem = items[items.length - 1];
const newItems = await fetchItems({ afterId: lastItem.id });
setItems((prev) => [...prev, ...newItems.data]);
setHasMore(newItems.hasMore);
} finally {
setIsLoading(false);
}
}, [items]);
return (
<div>
{items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
<InfiniteScroll
hasMore={hasMore}
isLoading={isLoading}
onLoadMore={loadMore}
/>
</div>
);
}
リスト末尾に<InfiniteScroll />を置くだけです。
まとめ
-
IntersectionObserverは要素が画面に入ったかを監視するブラウザAPI -
rootMarginで画面に入る手前に発火させると、スクロール体験がスムーズになる - Reactでは
useEffect+クリーンアップで使い、enabledフラグで監視のON/OFFを制御する