React HooksとIntersection Observer APIを使用したスクロール追跡のデモです.
ReactでAPIから配列データをfetchする際,データ量が多い場合はAPI側でページネーション対応していることがほとんどだと思います.
この場合,UIとしてはページネーション対応をすることになりますが,ページネーションはユーザーにとって使いづらいものです.
そこで,無限スクロールを実装することで,ユーザーにとって使いやすいUIを実現することができます.
Reactで無限スクロールの実装を調べましたが,scrollイベントを使用した実装が多く見られました.
しかしscrollイベントは,スクロールするたびにイベントが発火するため,パフォーマンスが悪くなります.
そこで他に良い実装がないか調べたところ,IntersectionObserverAPIを使用した実装が見つかりました.
見つけはしましたが,利用しないコンポーネントをレンダリングしたり,fetchしたデータを利用した実装は見つかりませんでした.
そこで,IntersectionObserverAPIを使用した動的データの無限スクロールを実装しました.
IntersectionObserverAPIは,特定の要素がビューポートまたは特定の要素と交差したときに通知を提供するWeb APIです.
このAPIを使用することで,スクロールイベントを使用することなく,要素の交差を監視することができます.
実装としては監視したい要素が配列の最後のコンポーネントとし,その要素がビューポートに入ったら,次のページのデータをfetchするようにしました.
詳しくは,MDNを参照してください.
import { useCallback, useEffect, useRef, useState, LegacyRef } from "react";
/**
* IntersectionObserverを利用するためのhook
* 無限スクロールなどで用いる
* https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
* @param {Object} options https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#parameters
* @return {[boolean,(function(*): void)]}
*/
export const useIntersection = (
options = {},
): [boolean, LegacyRef<HTMLLIElement>] => {
const [isIntersecting, setIsIntersecting] = useState(false);
const [refTrigger, setRefTrigger] = useState(0);
const ref = useRef<HTMLElement | null>(null);
const callbackRef = useCallback((element: HTMLElement) => {
if (element) {
ref.current = element;
setIsIntersecting(false);
setRefTrigger(Date.now());
}
}, []);
useEffect(() => {
if (refTrigger && ref.current) {
const observer = new IntersectionObserver(([entry], observer) => {
if (entry.isIntersecting) {
setIsIntersecting(true);
observer.unobserve(entry.target);
}
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [options, refTrigger]);
return [isIntersecting, callbackRef as LegacyRef<HTMLLIElement>];
};
Intersection Observer APIをReact Hooksで使いやすくするためのカスタムフックuseIntersectionObserver
を定義しています.
このHookは、指定したDOM要素がビューポートに入ったかどうかを監視し、その状態を返す機能を持っています.
useIntersectionObserverフックは、2つの引数を受け取ります.
一つ目はelementRef
で,これは監視対象となるDOM要素への参照です.
二つ目はoptions
で,これはIntersection Observer APIに渡す設定オプションです.
フックの内部ではまず,useState
を使ってisIntersecting
という状態を定義します.
これは監視対象の要素がビューポートに入っているかどうかを表します.
次にuseEffect
を使って副作用を定義します.
ここではIntersection Observerのインスタンスを作成し,監視対象の要素を登録します.
また,コンポーネントのアンマウント時に監視を解除するクリーンアップ処理も定義しています.
Intersection Observerのコールバック関数では,監視対象の要素がビューポートに入ったかどうかをentry.isIntersecting
から取得し,それをsetIsIntersecting
を使って状態に反映します.
最後にisIntersecting
の状態を返します.
これにより,このフックを使うコンポーネント側では監視対象の要素がビューポートに入ったかどうかを簡単に取得できます.
このカスタムフックを使うことで,Intersection Observer APIの複雑な設定や管理を隠蔽し,簡単にビューポートの監視を行うことができます.
これは,無限スクロールや遅延読み込み(lazy loading)などの機能を実装する際に非常に便利です,
import React from "react";
import useSWR from "swr";
import { useEffect, useState } from "react";
import { useIntersection } from "./libs/custom-hooks";
const fetcher = (url: string): Promise<GithubResponse> =>
fetch(url).then((res) => res.json());
// コンポーネント内で定義するとレンダリングの度に新しいオブジェクトが生成されるので外に定義
// もしくはuseStateやuseRefの初期値として定義
const options = {
rootMargin: "40px",
};
const App = () => {
const [page, setPage] = useState(1);
const [repos, setRepos] = useState<GithubRepository[]>([]);
const [isIntersecting, ref] = useIntersection(options);
useEffect(() => {
if (isIntersecting) {
setPage((p) => p + 1);
}
}, [isIntersecting]);
useSWR(
`https://api.github.com/search/repositories?q=language:js&per_page=10&page=${page}&sort=stars+updated`,
fetcher,
{
onSuccess: (data) => {
if (data && data.items.length > 0) {
setRepos((r) => {
const uniqueRepoIds = new Set(r.map((repo) => repo.id));
const uniqueRepos = data.items.filter(
(item) => !(item.id in uniqueRepoIds),
);
return [...r, ...uniqueRepos];
});
}
},
onError: (err) => {
alert(`エラーが発生しました: ${err.message}`);
},
},
);
return (
<ul style={{ paddingLeft: 0, margin: "1rem" }}>
{repos.map((repo, index) => (
<li
ref={index === repos.length - 1 ? ref : null}
key={repo.id + index} // GitHubのページネーションの仕様上、idが重複することがあるのでindexも含める
style={{
padding: "1rem",
border: "1rem solid",
boxSizing: "border-box",
listStyle: "none",
}}
>
<p>{repo.full_name}</p>
<span>{repo.description}</span>
<a href={repo.html_url}>{repo.html_url}</a>
</li>
))}
</ul>
);
};
export default App;
このコンポーネントはGitHubの公開APIを使用してJavaScriptのリポジトリをページネーションで取得し,それらを無限スクロールで表示する機能を提供しています。
swrを利用しているため,詳しくはswrを参照してください.
次に、options
オブジェクトを定義しています.これは後で使用するIntersection Observerの設定オプションで,ビューポートの下端から40pxの位置で交差を検出するように設定しています.
Appコンポーネントの中では,まずuseState
を使用してpage
とrepos
という2つの状態を作成しています.
page
は現在のページ番号を保持し,repos
は取得したリポジトリのリストを保持します.
次にカスタムフックuseIntersection
を使用して,ビューポートとの交差を監視します.
useEffectフックの中ではisIntersecting
がtrue
になったとき(つまりref
がビューポートに入ったとき)にページ番号を増やします.
これにより,ユーザーがページの一番下までスクロールしたときに次のページのデータを自動的にロードする無限スクロールの挙動を実現しています.
useSWRフックを使用して,GitHubのAPIからリポジトリのデータを非同期に取得します.
このフックはデータの取得とキャッシュ,再取得の機能を提供します.
ここでは取得成功時には新たに取得したリポジトリをrepos
に追加し,エラー発生時にはアラートを表示するようにしています.
最後に,取得したリポジトリのリストをループして表示します.
ここではリストの最後の要素にref
を関連付けて,この要素がビューポートに入ると次のページのデータをロードするようにしています.
React HooksとIntersection Observer APIを組み合わせて無限スクロールを実装しました.