0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IntersectionObserverとは?Reactで無限スクロールを作るまで

0
Posted at

はじめに

「リストの一番下までスクロールしたら、次のデータを読み込む」いわゆる無限スクロールを実装するとき、IntersectionObserverというブラウザAPIが役立ちます。

業務で実際にこのAPIを使って無限スクロールを実装したので、Reactで無限スクロールを実現するための方法をメモがわりにまとめます。

対象読者

  • IntersectionObserverを使ったことがない方
  • 無限スクロールをライブラリなしで実装したい方

環境

  • React 18以降
  • TypeScript

IntersectionObserverとは

ある要素が画面に入ったかどうかを監視するブラウザAPIです。

素のJavaScriptで書くとこうなります。

// 1. 「要素が画面に入ったら何をする?」を定義
const observer = new IntersectionObserver((entries) => {
  if (entries[0]?.isIntersecting) {
    console.log('画面に入った!');
  }
});

// 2. 監視を開始
const target = document.getElementById('sentinel');
observer.observe(target);

// 3. 不要になったら解除
observer.disconnect();

これだけです。observeで監視を始め、要素が画面に入るとisIntersectingtrueになってコールバックが呼ばれます。

rootMarginで「手前」に発火させる

オプションのrootMarginを使うと、要素が画面に入る手前でコールバックを発火できます。

const observer = new IntersectionObserver(callback, {
  rootMargin: '200px', // 画面の200px手前で発火
});

無限スクロールではこれが重要です。ユーザーがリスト末尾に到達する前にデータの読み込みを開始できるので、待ち時間が減ります。

Reactで使う:useIntersectionフック

ReactではuseEffectの中でobserveし、クリーンアップでdisconnectするのが基本です。これをカスタムフックにまとめます。

hooks/useIntersection.ts
import { useEffect, type RefObject } from 'react';

export const useIntersection = (
  ref: RefObject<HTMLElement | null>,  // 監視したいDOM要素への参照
  onIntersect: () => void,             // 画面に入ったら呼ぶ関数
  { enabled = true, rootMargin = '0px' }: { enabled?: boolean; rootMargin?: string } = {},
) => {
  useEffect(() => {
    // ① enabledがfalseなら何もしない(読み込み中などに監視を止める)
    if (!enabled) return;

    // ② refから実際のDOM要素を取り出す
    const target = ref.current;
    if (!target) return;

    // ③ IntersectionObserverを作る(ここではまだ監視は始まっていない)
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) {
          onIntersect(); // ⑤ 画面に入った!→ コールバック実行
        }
      },
      { rootMargin },
    );

    // ④ 監視を開始する(ここで初めてブラウザが要素を見張り始める)
    observer.observe(target);

    // ⑥ クリーンアップ:コンポーネントが消えた or 依存が変わった時に監視を解除
    return () => observer.disconnect();
  }, [ref, onIntersect, enabled, rootMargin]);
};

フックの中で何が起きているか

時系列で追うとこうなります。

コンポーネントがマウントされる
  → useEffectが実行される
    → ①② チェックを通過
    → ③④ Observerを作って監視開始
    →(ブラウザがバックグラウンドで要素を見張っている)

ユーザーがスクロールしてsentinelが画面に近づく
    → ⑤ ブラウザが「画面に入ったよ」とコールバックを呼ぶ
    → onIntersect()が実行される(= loadMore)

enabledがfalseに変わる(読み込み中)
    → useEffectの依存が変わったので、まず⑥のクリーンアップが走る(監視解除)
    → useEffectが再実行されるが、①でreturnして何もしない

enabledがtrueに戻る(読み込み完了)
    → useEffectが再実行 → ③④で新しいObserverを作って監視再開

つまりenabledが変わるたびにObserverを作り直すことで、監視のON/OFFを制御しています。

⑥のreturn () => observer.disconnect()を忘れると、コンポーネントがアンマウントされた後もオブザーバーが動き続けてメモリリークになります。

無限スクロールコンポーネントを作る

useIntersectionを使って、リスト末尾に置くだけで動く無限スクロールコンポーネントを作ります。

components/InfiniteScroll.tsx
'use client';

import { useIntersection } from '@/hooks/useIntersection';
import { useRef } from 'react';

export function InfiniteScroll({
  hasMore,
  isLoading,
  onLoadMore,
  rootMargin = '200px',
}: {
  hasMore: boolean;
  isLoading: boolean;
  onLoadMore: () => void;
  rootMargin?: string;
}) {
  const sentinelRef = useRef<HTMLDivElement>(null);

  useIntersection(sentinelRef, onLoadMore, {
    enabled: hasMore && !isLoading,
    rootMargin,
  });

  return (
    <div ref={sentinelRef} style={{ minHeight: 1 }} aria-hidden>
      {isLoading && <p>読み込み中...</p>}
    </div>
  );
}

minHeight: 1を指定しているのは、高さ0の要素はIntersectionObserverに検知されないことがあるためです。面積がないと「ビューポートと交差した」と判定されず、コールバックが呼ばれません。最低1pxの高さを確保することで確実に検知されるようにしています。

仕組み

  ┌───────────────────┐
  │ アイテム 1          │
  │ アイテム 2          │
  │ アイテム 3          │
  │ ...                │
  └───────────────────┘
                          ← rootMargin: 200px(ここに来たら発火)
  ┌───────────────────┐
  │ sentinel(1px)     │  ← IntersectionObserverが監視
  └───────────────────┘
  1. リスト末尾にsentinel(番兵)要素を置く
  2. sentinelが画面に近づくとonLoadMoreが呼ばれる
  3. 読み込み中はenabled: falseで監視を一時停止
  4. 読み込み完了で監視再開 → 繰り返し

使う側

ItemList.tsx
'use client';

import { useCallback, useState } from 'react';
import { InfiniteScroll } from '@/components/InfiniteScroll';

export default function ItemList() {
  const [items, setItems] = useState<Item[]>(initialItems);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);

  const loadMore = useCallback(async () => {
    setIsLoading(true);
    try {
      const lastItem = items[items.length - 1];
      const newItems = await fetchItems({ afterId: lastItem.id });
      setItems((prev) => [...prev, ...newItems.data]);
      setHasMore(newItems.hasMore);
    } finally {
      setIsLoading(false);
    }
  }, [items]);

  return (
    <div>
      {items.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
      <InfiniteScroll
        hasMore={hasMore}
        isLoading={isLoading}
        onLoadMore={loadMore}
      />
    </div>
  );
}

リスト末尾に<InfiniteScroll />を置くだけです。

まとめ

  • IntersectionObserverは要素が画面に入ったかを監視するブラウザAPI
  • rootMarginで画面に入る手前に発火させると、スクロール体験がスムーズになる
  • ReactではuseEffect+クリーンアップで使い、enabledフラグで監視のON/OFFを制御する

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?