Logo
About

Reactで無限スクロール

2023/01/10

TL;DR#


React HooksとIntersection Observer APIを使用したスクロール追跡のデモです.

github repo

はじめに#


ReactでAPIから配列データをfetchする際,データ量が多い場合はAPI側でページネーション対応していることがほとんどだと思います.
この場合,UIとしてはページネーション対応をすることになりますが,ページネーションはユーザーにとって使いづらいものです.
そこで,無限スクロールを実装することで,ユーザーにとって使いやすいUIを実現することができます.

Reactで無限スクロールの実装を調べましたが,scrollイベントを使用した実装が多く見られました.
しかしscrollイベントは,スクロールするたびにイベントが発火するため,パフォーマンスが悪くなります.

そこで他に良い実装がないか調べたところ,IntersectionObserverAPIを使用した実装が見つかりました.
見つけはしましたが,利用しないコンポーネントをレンダリングしたり,fetchしたデータを利用した実装は見つかりませんでした.

そこで,IntersectionObserverAPIを使用した動的データの無限スクロールを実装しました.

Intersection Observer APIとは#


IntersectionObserverAPIは,特定の要素がビューポートまたは特定の要素と交差したときに通知を提供するWeb APIです.
このAPIを使用することで,スクロールイベントを使用することなく,要素の交差を監視することができます.
実装としては監視したい要素が配列の最後のコンポーネントとし,その要素がビューポートに入ったら,次のページのデータをfetchするようにしました.

詳しくは,MDNを参照してください.

custom-hookの作成#


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)などの機能を実装する際に非常に便利です,

custom-hookを利用した無限スクロールの実装#


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を使用してpagereposという2つの状態を作成しています.
pageは現在のページ番号を保持し,reposは取得したリポジトリのリストを保持します.

次にカスタムフックuseIntersectionを使用して,ビューポートとの交差を監視します.
useEffectフックの中ではisIntersectingtrueになったとき(つまりrefがビューポートに入ったとき)にページ番号を増やします.
これにより,ユーザーがページの一番下までスクロールしたときに次のページのデータを自動的にロードする無限スクロールの挙動を実現しています.

useSWRフックを使用して,GitHubのAPIからリポジトリのデータを非同期に取得します.
このフックはデータの取得とキャッシュ,再取得の機能を提供します.
ここでは取得成功時には新たに取得したリポジトリをreposに追加し,エラー発生時にはアラートを表示するようにしています.

最後に,取得したリポジトリのリストをループして表示します.
ここではリストの最後の要素にrefを関連付けて,この要素がビューポートに入ると次のページのデータをロードするようにしています.

まとめ#


React HooksとIntersection Observer APIを組み合わせて無限スクロールを実装しました.