Intersection Observer APIを使って無限スクロールをReactでつくってみた


はじめに

一番下までスクロールされたら次のコンテンツを読み込んで表示する無限スクロールをライブラリを使わずにIntersection Observer APIを利用して実装したのでその記録を書きました。
記事用のコードなのでjsonplaceholderからデータを取得しています。

https://jsonplaceholder.typicode.com/
交差オブザーバーAPI(Intersection Observer API)についてはこちらを参考にしています。こちらの説明は省略しているのでわからないところがあれば適時こちらの記事を読んでいただければと思います。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

loadingやerror時の処理は今回は省略しています...(・・)
カスタムフックでロジック部分の分離はしてません...(・・)

動作

こんな感じです。ScrollObserverコンポーネントはわかりやすく赤色の背景にしていて、こいつが交差したら関数が実行されるイメージです。

全体のコード

App.jsx
import { useCallback, useEffect, useState } from "react";

import "./App.css";
import { ScrollObserver } from "./ScrollObserver";

function App() {
  const [todos, setTodos] = useState([]);
  const [isActiveObserver, setIsActiveObserver] = useState(true);

  const fetchTodos = useCallback(async () => {
    const res = await fetch(
      "https://jsonplaceholder.typicode.com/todos?_limit=10"
    );
    const json = await res.json();
    setTodos(json);
  }, []);

  useEffect(() => {
    fetchTodos();
  }, [fetchTodos]);

  const fetchNextTodos = useCallback(async () => {
    const res = await fetch(
      `https://jsonplaceholder.typicode.com/todos?_start=${todos.length}&_limit=10`
    );
    const json = await res.json();
    // データをすべて取得したとき
    if (json.length === 0) {
      return setIsActiveObserver(false);
    }
    setTodos([...todos, ...json]);
  }, [todos]);

  return (
    <div className="App">
      <h1>無限スクロール</h1>
      <div className="container">
        <ol>
          {todos.map((todo) => {
            return <li key={todo.id}>{todo.title}</li>;
          })}
        </ol>
        <ScrollObserver
          onIntersect={fetchNextTodos}
          isActiveObserver={isActiveObserver}
        />
      </div>
    </div>
  );
}

export default App;

ScrollObserver.jsx
import { memo, useEffect, useRef } from "react";

export const ScrollObserver = memo((props) => {
  const { onIntersect, isActiveObserver } = props;
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries, observer) => {
        if (entries[0].intersectionRatio >= 1) {
          observer.disconnect();
          onIntersect();
        }
      },
      {
        threshold: 1,
      }
    );
    observer.observe(ref.current);
  }, [onIntersect]);

  return (
    <>
      {isActiveObserver ? (
        <div ref={ref} style={{ height: "50px", backgroundColor: "red" }}>
          <p>読み込み中...</p>
        </div>
      ) : null}
    </>
  );
});

コードの解説

初期データの取得

まずはこちらで最初にデータを10件取得しています。

App.jsx
  const fetchTodos = useCallback(async () => {
    const res = await fetch(
      "https://jsonplaceholder.typicode.com/todos?_limit=10"
    );
    const json = await res.json();
    setTodos(json);
  }, []);

  useEffect(() => {
    fetchTodos();
  }, [fetchTodos]);

交差時実行したい関数作成

こちらはScrollObserverコンポーネントにわたす関数で交差時にしてほしい処理です。todosの配列の最後から10件取得するようにクエリパラメーターを書いています。依存配列にtodosがあるのはtodosの値が変わるとtodos.lengthの長さも変わるので、メモ化した値を再計算しています。取得するデータがなくなってもScrollObserverの監視状態が続いてしまっていたので、データを取得したらisActiveObserverをfalseにしてScrollObserverコンポーネントの返り値をnullにしています。この辺もうすこし簡潔に書けないか気になるのでアドバイスあればコメントしてほしいです。

App.jsx
  const fetchNextTodos = useCallback(async () => {
   const res = await fetch(
     `https://jsonplaceholder.typicode.com/todos?_start=${todos.length}&_limit=10`
   );
   const json = await res.json();
   // データをすべて取得したとき
   if (json.length === 0) {
     return setIsActiveObserver(false);
   }
   setTodos([...todos, ...json]);
 }, [todos]);

ScrollObserverコンポーネント

こちらはpropsで交差時に実行したい関数(今回だとfetchNextTodos)とbooleanのisActiveObserverを受け取っています。
useRefはオブザーバーを作成した後、監視するターゲット要素を与える必要があるので使用しています。useRefの詳細は公式ドキュメントを参考にしてください。

https://ja.reactjs.org/docs/hooks-reference.html#useref

交差時の処理としてはIntersectionObserverの第一引数でおこなっており、ずっと監視しているとonIntersect()関数が何度もよばれるためobserver.disconnect()で一度監視をやめてます。onIntersectを依存配列にいれているためそちらの関数が更新時(この例ではtodosの値が代わったとき)にもう一度監視している状態にしています。第2引数は交差関連のoptionです。

ScrollObserver
  useEffect(() => {
  if (ref.current === null) return;
  const observer = new IntersectionObserver(
    (entries, observer) => {
      if (entries[0].intersectionRatio >= 1) {
        observer.disconnect();
        onIntersect();
      }
    },
    {
      threshold: 1,
    }
  );
  observer.observe(ref.current);
}, [onIntersect]);

こちらはisActiveObserverがtrueのとき(まだ取得できるtodoが残っているとき)、交差すると交差時の処理が行われる要素を入れ、isActiveObserverがfalseのときはnullを返しています。

ScrollObserver.jsx
  return (
  <>
    {isActiveObserver ? (
      <div ref={ref} style={{ height: "50px", backgroundColor: "red" }}>
        <p>読み込み中...</p>
      </div>
    ) : null}
  </>
);

おわりに

今回は初めてIntersection Observer APIを利用して無限スクロールの実装をしたので、不出来なところがあるかもしれませんが参考になれば幸いです!間違っているところやもっとよくなるところがあればコメントお願いします(_ _)