🚴‍♂️

IntersectionObserverとSWRで無限ローディングを実装するときはuseRefに気をつけよう

2021/09/15に公開

一番下までスクロールされたら次ページ分のコンテンツを読み込んで追加で表示する、といった無限ローディングの実装を行うときにハマったので記録しておきます。

最小限の動作する例

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)を実行しているのと同じだということです。
これではsize2より大きくなることはないため、永遠に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に渡した値はコンポーネントのマウント時に一度だけ評価され、コンポーネントの再レンダリング時には評価されないということをきちんと理解しておくと良さそうです。

リポジトリ

https://github.com/EringiV3/intersection-observer-swr-infinite-loading-example

Discussion