IntersectionObserverとSWRで無限ローディングを実装するときはuseRefに気をつけよう
一番下までスクロールされたら次ページ分のコンテンツを読み込んで追加で表示する、といった無限ローディングの実装を行うときにハマったので記録しておきます。
最小限の動作する例
import React, { useEffect, useRef, useState } from 'react';
import useSWRInfinite from 'swr/infinite';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const PAGE_SIZE = 10;
const App: React.FC = () => {
const { data, setSize, isValidating } = useSWRInfinite(
(index) =>
`https://api.github.com/search/repositories?q=react&per_page=${PAGE_SIZE}&page=${
index + 1
}`,
fetcher
);
const repos: any[] = data ? data.map((v) => v.items).flat() : [];
const [lastElement, setLastElement] = useState<HTMLDivElement | null>(null);
const rootElement = useRef<HTMLDivElement | null>(null);
const observer = useRef(
new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting) {
setSize((size) => size + 1);
}
},
{ root: rootElement.current, rootMargin: '0px', threshold: 0 }
)
);
useEffect(() => {
const currentElement = lastElement;
const currentOvserver = observer.current;
if (currentElement) {
currentOvserver.observe(currentElement);
}
return () => {
currentOvserver.disconnect();
};
}, [lastElement]);
return (
<>
<div ref={rootElement}>
{repos?.map((repo, i) => (
<div
key={repo.id}
ref={
i === repos.length - 1
? (ref) => {
setLastElement(ref);
}
: undefined
}
style={{ height: '20vh' }}
>
- {repo.name} {`(${repo.html_url})`}
</div>
))}
</div>
{isValidating && <div>loading...</div>}
</>
);
};
export default App;
ひとつひとつ追っていきます。
「一番下までスクロールされたかどうか」を判定する
一番最後の表示要素が画面内に描画されているかどうかを見て、「一番下までスクロールされたかどうか」を判定します。
一番最後の表示要素が画面内に描画されているかどうかは、IntersectionObserver
を使って判定します。
IntersectionObserver
の使い方については詳細を省きます。
まずは、一番最後の要素だけを常に参照することができるようにします。
<div ref={rootElement}>
{repos?.map((repo, i) => (
<div
key={repo.id}
// 一番最後の要素のrefだけstateで保持する
ref={
i === repos.length - 1
? (ref) => {
setLastElement(ref);
}
: undefined
}
style={{ height: '20vh' }}
>
- {repo.name} {`(${repo.html_url})`}
</div>
))}
</div>
次に、IntersectionObserver
のインスタンスを作成します。
IntersectionObserver
のインスタンスはコンポーネントがマウントされた後、一度だけインスタンスが作成されるのが望ましいです。
そのため、useRef
を使って、マウントされた時に一度だけインスタンスを作成するようにします。
// 何度レンダリングされても同じインスタンスを参照する
const observer = useRef(
new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting) {
// 次ページ分のデータをフェッチ
setSize((size) => size + 1);
}
},
{ root: rootElement.current, rootMargin: '0px', threshold: 0 }
)
);
ここでuseRef
を使わなかった場合、コンポーネントが再レンダリングされるたびにIntersectionObserver
のインスタンスが作成されるので、最後の要素が画面内に描画された時の処理が重複して実行されてしまいます。
また、以下も正しく動きません。
const observer = useRef(
new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting) {
// sizeはuseSWRInfiniteの戻り値を使用
setSize(size + 1);
}
},
{ root: rootElement.current, rootMargin: '0px', threshold: 0 }
)
);
理由は、useRef
に渡した値はコンポーネントのマウント時に評価され、それ以降の再レンダリング時には評価されないためです。
つまり、size
の初期値が1
だとするとsetSize(size + 1)
は、最後の要素が画面内に描画されるたびにsetSize(1 + 1)
を実行しているのと同じだということです。
これではsize
が2
より大きくなることはないため、永遠に3ページ目以降が表示されません。
そのため、useSWRInfinite
の戻り値のsize
を参照するのではなく、setSize
にコールバック関数を渡してsize
を更新するようにしましょう。
これで3ページ目以降も表示されます。
表示要素が増えるたびに監視する要素を変更する
データをフェッチしたら表示要素が増えるので、「最後の要素」の監視も更新する必要があります。
useEffect
で最後の要素に変更があるたびに、監視し直しましょう。
useEffect(() => {
const currentElement = lastElement;
const currentOvserver = observer.current;
if (currentElement) {
currentOvserver.observe(currentElement);
}
return () => {
// アンマウント時に監視を解除
currentOvserver.disconnect();
};
}, [lastElement]);
まとめ
useRef
に渡した値はコンポーネントのマウント時に一度だけ評価され、コンポーネントの再レンダリング時には評価されないということをきちんと理解しておくと良さそうです。
リポジトリ
Discussion