はじめに
Reactでスクロールすると次のデータを読み込むcustom hooksを作成します。実装には交差オブザーバーAPIを利用します。
動作は下のcodesandboxで見ることができます。コードだけ見たい方はcodesandboxを見てください。
交差オブザーバーAPI
対象の要素がビューポート(表示されている画面の領域)または指定した要素と交差したことを検知するAPIで、Intersection Observer APIと呼ばれています。
IntersectionObserverのコンストラクターを利用してIntersectionObserverオブジェクトを作ります。
const observer = new IntersectionObserver(callback[, options]);
callbackには交差したタイミングで実行される関数を渡します。このcallback関数は閾値や交差したタイミングを司るIntersectionObserverEntryの配列のentriesとコールバックが呼び出される(つまり呼び出し元自身の)IntersectionObserverのobserverの二つの引数を受け取ります。
optionsはIntersectionObjectをカスタマイズするためのオプションオブジェクトです。このオブジェクトは3つの組み合わせを持ちます。
root交差されることを待つ領域となる要素とrootMarginrootのマージン、threshold指定した領域に対象の要素がどれだけ入ったときに発火するかの閾値(0.0~1.0)を持つことができます。
例として、targetがareaに交差したらalertを吐き出すようなコードは以下のように書きます。
const object = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
alert('交差しました');
}
});
}, {
root: area,
});
object.observe(target);
IntersectionObserverのメソッドとして実行したobserveは監視対象を登録するもので、unobserveで解除することができます。
実装
observerを使って交差したときに非同期関数を読み込むようなcustom hooksを作成します。今回作成するようなどこでも使えるような機能はcustom hooksにすることでその度使いまわせるのでおすすめです。最小限で作るのでのちに紹介する拡張など、お好みにカスタマイズして使ってください。
hooksの実装
hooksの実装は以下のようになります。
import { RefObject, useCallback, useEffect, useState } from 'react';
export const useInfinityScroll = <T>(
ref: RefObject<HTMLElement | null>,
fetch: (page: number) => Promise<T[]>,
) => {
const [data, setData] = useState<T[]>([]);
const [page, setPage] = useState(1);
const scrollObserver = useCallback(
() =>
new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
fetch(page).then((data) => {
setPage(page + 1);
setData((oldValue) => [...oldValue, ...data]);
});
}
});
}),
[page, fetch],
);
useEffect(() => {
const target = ref.current;
if (target) {
const observer = scrollObserver();
observer.observe(target);
return () => {
observer.unobserve(target);
};
}
}, [scrollObserver, ref]);
return data;
};
このhooksは監視する対象をrefとしてReact.RefObjectを渡して、ページに依存するデータ取得関数をfetchとして(page: number) => Promise<T[]>を引数として渡します。Tはデータによって異なるようにするためにジェネリクス型引数を利用してそれに合わせた型に変化させます。
まず、useStateで状態を定義します。表示するデータと表示するデータのページ数を定義しました。
次にIntersectionObserverオブジェクトを作る関数scrollObserverです。この関数は一旦useCallbackのことを忘れるとIntersectionObserverのコンストラクタに交差したときにfetch関数を呼び出して取得が完了したらsetPageとsetDataを呼び出し状態を更新するようなcallbackを渡してIntersectionObserverオブジェクトを作ってます。scrollObserverは後ほど紹介するuseEffect内で動作させさせますから、レンダリングのたび新しい関数を作り直すのではなく、pageとfetchが変わらない限りレンダリングのたびに関数を作り直させないようにuseCallbackを利用しています。
最後にuseEffectです。先述の通りscrollObserverを呼び出すためのものです。まず、渡されたrefからcurrentを取得したtargetを定義してそれがnullではないか確認します。nullでなければscrollObserverを実行して取得したIntersectionObserverオブジェクトにtargetを監視対象として登録します。そして、最後にマウント時に行なった副作用は可能な限りアンマウント時に解除しなければいけないのでクリーンアップ関数でIntersectionObserverのunobserve関数で監視を解除します。なぜそのようにしなければならないのかと感じた方はReactのbeta版のドキュメントを読むと参考になると思います。最初はReactの開発環境でuseEffectが2回実行されて困ったらクリーンアップを見直すところから始めていくと慣れていくと思います。useEffectの依存配列にはscrollObserverとrefを渡しています。scrollObserverはpageが変わるたびに監視を再定義して欲しいので追加しています。refはcurrentがnullからHTMLElementになった時などに発火して欲しいので追加しています。
そして、dataを返しています。このデータはrefがビューポートに触れるたびに更新されるデータとなっているのでこのhooksを使う側でmap関数などで配置してあげれば自由に拡張されます。このhooksは説明のため最小限で作りましたが、ビューポートじゃなくて指定した要素に触れたときに発火するように拡張するなど使いやすいように変更しても良いかと思われます。
hooksを使った無限スクロールの紹介
hooksの作成が完了したので実際にそれを使った無限スクロールの実装例を紹介します。ここでは100件ずつ数値を取得する非同期関数を読み込み続けるものを作成します。
データ取得関数ではないですがnumGeneratorファイルを作成してperOneHundredというpageを渡すとそのページの数値配列を返す非同期関数を作成して行います。この関数の解説はこの記事の本筋ではないので飛ばします。
export const perOneHundred = async (page: number): Promise<number[]> => {
function* range(start: number, end: number) {
for (let i = start; i < end; i++) {
yield i;
}
}
return new Promise((resolve) => {
resolve([...range((page - 1) * 100, page * 100)]);
});
};
これで先ほどのhooksを利用すると以下のような無限スクロールが可能になります。
listの最後に交差を検証する対象の要素を置いて、それが画面に現れたら読み込むような仕組みです。hooksを少し変更してloading状態も返すようにすると読み込み中の表示を追加できるのでおすすめです。