♾️

Reactで無限スクロール

に公開

TL;DR

React HooksIntersection Observer APIを使用したスクロール追跡のデモです。

https://github.com/yut-kt/demo-intersection-observer-hook

はじめに

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

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 APIReact 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 HooksIntersection Observer APIを組み合わせて無限スクロールを実装しました。

Discussion