SCALE — Build Lab
開発パターン · REACT HOOK

無限スクロール(IntersectionObserver)

CATEGORY開発パターン TYPEReact Hook EFFORT60〜120分 DIFFICULTY
PRIMARY CODE
tsx
import { useEffect, useRef, useState } from 'react';

export function useInfiniteScroll<T>(fetcher: (page: number) => Promise<T[]>) {
  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [done, setDone] = useState(false);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!sentinelRef.current) return;
    const io = new IntersectionObserver(async (entries) => {
      if (!entries[0].isIntersecting || loading || done) return;
      setLoading(true);
      try {
        const next = await fetcher(page);
        if (next.length === 0) setDone(true);
        else { setItems(prev => [...prev, ...next]); setPage(p => p + 1); }
      } finally { setLoading(false); }
    }, { rootMargin: '200px' });
    io.observe(sentinelRef.current);
    return () => io.disconnect();
  }, [page, loading, done, fetcher]);

  return { items, loading, done, sentinelRef };
}

// 使い方:
// const { items, loading, sentinelRef } = useInfiniteScroll(p => api.fetch(p));
// {items.map(...)}
// <div ref={sentinelRef}>{loading ? 'Loading...' : null}</div>
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 任意のダッシュボードに組み込み

無限スクロール(IntersectionObserver)

:LiTarget: 用途

末尾の sentinel 要素が見えたら次ページ読込。IntersectionObserver で軽量。

:LiSparkle: 特徴

  • IntersectionObserver
  • ローディング状態
  • 終端検知
  • エラーハンドル

:LiCode: コード(コピペ用)

import { useEffect, useRef, useState } from 'react';

export function useInfiniteScroll<T>(fetcher: (page: number) => Promise<T[]>) {
  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [done, setDone] = useState(false);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!sentinelRef.current) return;
    const io = new IntersectionObserver(async (entries) => {
      if (!entries[0].isIntersecting || loading || done) return;
      setLoading(true);
      try {
        const next = await fetcher(page);
        if (next.length === 0) setDone(true);
        else { setItems(prev => [...prev, ...next]); setPage(p => p + 1); }
      } finally { setLoading(false); }
    }, { rootMargin: '200px' });
    io.observe(sentinelRef.current);
    return () => io.disconnect();
  }, [page, loading, done, fetcher]);

  return { items, loading, done, sentinelRef };
}

// 使い方:
// const { items, loading, sentinelRef } = useInfiniteScroll(p => api.fetch(p));
// {items.map(...)}
// <div ref={sentinelRef}>{loading ? 'Loading...' : null}</div>

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加