Reactで実装する無限スクロール
はじめに
業務で携わっているサービスで無限スクロールを実装しました。
無限スクロールはライブラリを使って実装している記事が多いですが、今回はライブラリを使わずに実装してみました。
IntersectionObserverやscrollイベントなどで知らないことが多かったので自身の脳内整理のために書いていこうと思います。
使用した技術
- React
- TypeScript
- Apollo Client: GraphQLクエリの実行
- IntersectionObserver API: スクロールの検出
- カスタムUIコンポーネント: Atomic Designに基づいたコンポーネント
IntersectionObserverとは
IntersectionObserverやスクロールそのものについての知識がなかったので調べてみました。
IntersectionObserverは、ページ上の特定の要素が別の要素(ルート要素、通常はブラウザのビューポート)と交差するタイミングを非同期的に監視するためのWeb APIです。
従来、スクロールに合わせて要素を操るにはscrollというイベントを使う必要がありました。
scrollの場合はスクロールや画面のリサイズのたびに関数を呼び出すので、パフォーマンスがIntersectionObserverに比べると良くないそうです。
IntersectionObserverのメリットは以下の通りです。
パフォーマンスの向上
IntersectionObserverはスクロールやリサイズのたびに関数を呼び出すわけではなく、必要なタイミングでのみコールバック関数が実行されるため、パフォーマンスへの影響が少なくなります。
柔軟な設定
ルート要素、マージン、閾値などを細かく設定できるため、様々なユースケースに対応できます。
簡潔なコード
ビューポートサイズの変更に伴う再計算や、スクロール量の管理などを自動的に行うためコードがシンプルになります。
IntersectionObserverにより、今回行う無限スクロールのような実装で複雑なスクロール計算やイベント管理から解放され、さらに効率的で保守性の高いコードを書くことができるそうです。
データ構造と取得
架空のRecordテーブルからUserごとのデータを取得します。
Recordには以下の情報が含まれます。
- id
- title
- content
Apollo Clientを使用してGraphQLクエリを実行します。
import { useQuery } from "@apollo/client";
const { data: records, loading, fetchMore } = useQuery(GetRecordsByUserDocument, {
variables: {
userId: Number(user.id),
page: page,
take: 10,
},
notifyOnNetworkStatusChange: true,
});
このクエリは以下のパラメータを受け取ります。
- userId: ユーザーのID
- page: ページネーションのためのページ番号
- take: 1回のクエリで取得する記録の数
無限スクロールの実装
ページでの無限スクロールは、IntersectionObserverを利用して実装しました。
スクロールイベントを監視し、スクロールがページの下部に到達した時に新しいデータを読み込みます。
// 現在のページ番号
const [page, setPage] = useState(1);
// さらにデータがあるかどうか
const [hasMore, setHasMore] = useState(true);
// 取得したレコードを保持
const [recordsState, setRecordsState] = useState<RecordFormInterface[]>([]);
const lastElementRef = useRef<HTMLDivElement>(null);
// 次のページのデータを取得
const loadMore = () => {
if (hasMore && !loading) {
fetchMore({
variables: { page: page + 1 },
}).then(({ data }) => {
if (data.getRecordsByUser.length > 0) {
setRecordsState((prevRecords) => [...prevRecords, ...newRecords]);
setPage((prevPage) => prevPage + 1);
setHasMore(data.getRecordsByUser.length === 10);
} else {
setHasMore(false);
}
});
}
};
// IntersectionObserver の設定
useEffect(() => {
const observer = new IntersectionObserver(handleObserver, { threshold: 1 });
if (lastElementRef.current) {
observer.observe(lastElementRef.current);
}
return () => {
if (lastElementRef.current) {
observer.unobserve(lastElementRef.current);
}
};
}, [hasMore, loading]);
// 最後の要素の参照
<div ref={lastElementRef}>
{loading && <AtomLoader />}
</div>
scrollイベントとIntersectionObserverのパフォーマンス比較
IntersectionObserverについて調べた時に、scrollイベントはIntersectionObserverに比べてパフォーマンスが悪いとありました。
どのくらい差があるのか気になったので、試しにscrollイベントでも実装し、コンソールに呼び出し回数と読み込み回数を出力してみました。
InterSectionObserverのコード
// 上記のコードから変更した箇所のみ記載
const [observerCount, setObserverCount] = useState(0);
const [loadCount, setLoadCount] = useState(0);
const loadMore = () => {
if (hasMore && !loading) {
setLoadCount(prevCount => prevCount + 1);
fetchMore({
variables: {
page: page + 1,
},
}).then(({ data }) => {
const newRecords = data.getLearningRecordsByShopRelationUser.map(
convertToRecordFormInterface,
);
if (newRecords.length > 0) {
setRecordsState((prevRecords) => [...prevRecords, ...newRecords]);
setPage((prevPage) => prevPage + 1);
setHasMore(newRecords.length === 10);
} else {
setHasMore(false);
}
});
}
};
const handleObserver = (entries: IntersectionObserverEntry[]) => {
setObserverCount(prevCount => prevCount + 1);
if (entries[0]?.isIntersecting && hasMore && !loading) {
loadMore();
}
};
useEffect(() => {
console.log(`Observer count: ${observerCount}, Load count: ${loadCount}`);
}, [observerCount, loadCount]);
scrollイベントのコード
// stateはIntersectionObserverのものと同じ
// 出力用のステート
const [scrollCount, setScrollCount] = useState(0);
const [loadCount, setLoadCount] = useState(0);
// 次のページのデータを取得
const loadMore = () => {
if (hasMore && !loading) {
setLoadCount(prevCount => prevCount + 1);
fetchMore({
variables: {
page: page + 1,
},
}).then(({ data }) => {
const newRecords = data.getLearningRecordsByShopRelationUser.map(
convertToRecordFormInterface,
);
if (newRecords.length > 0) {
setRecordsState((prevRecords) => [...prevRecords, ...newRecords]);
setPage((prevPage) => prevPage + 1);
setHasMore(newRecords.length === 10);
} else {
setHasMore(false);
}
});
}
};
const handleScroll = () => {
setScrollCount(prevCount => prevCount + 1);
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
if (windowHeight + scrollTop >= documentHeight - 100) {
if (hasMore && !loading) {
loadMore();
}
}
};
// (無限スクロール)IntersectionObserverを設定し、最後の要素を監視
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [hasMore, loading]);
// スクロールイベントの発生回数とデータ読み込みの回数を出力
useEffect(() => {
console.log(`Scroll count: ${scrollCount}, Load count: ${loadCount}`);
}, [scrollCount, loadCount]);
scrollイベント | InterSectionObserver |
---|---|
![]() |
![]() |
上記の結果を見ると、Load Countが0から2になるまでにscrollイベントの方はコールバックの呼び出し回数が100以上になっているのに対し、IntersectionObserverを使用した場合は10程度になっています。
よって、IntersectionObserverの方がコールバックの呼び出し回数が大幅に少なく、パフォーマンスがより良いことが確認できました。
おわりに
フロントエンドを触り始めて数ヶ月経ちましたが、無限スクロールのような日常的に他のサービスで使っている機能の仕組みを知ることができて面白かったです。
実際に自分の手でその仕組みを実装してみると、何とか形にはなったものの実装方法に改善の余地がありそうなので今後改善していきたいです。
Discussion
自分も同じような記事を書いたけど、IntersectionObserverの方がscrollよりパフォーマンス良いのは知りませんでした!