[250611] TIL

Today I Learned (2025-06-11)

IntersectionObserver로 무한 스크롤 구현하기

  • 브라우저 제공 클래스
  • 전역 객체 window에 존재

범용적으로 사용 할 수 있는 커스텀 훅 구현

  • 댓글에서도 사용되고 기록에서도 사용되어서 범용적으로 사용 가능하도록 구현했다.
  • useQuery 훅을 사용해서 효율적으로 API 호출하는 방법도 있던데 그것도 공부해보면 좋을 것 같다.
import { useEffect, useRef, useState } from "react";

import { PaginationList } from "@/shared/types";

interface InfiniteScrollProps<T> {
  initialData: PaginationList<T>;
  fetchMore: (params: {
    limit: number,
    cursor: string | null,
  }) => Promise<PaginationList<T>>;
}

export function useInfiniteScroll<T>({
  initialData,
  fetchMore,
}: InfiniteScrollProps<T>) {
  const [items, setItems] = useState(initialData.items);
  const [pagination, setPagination] = useState(initialData.pagination);
  const [isLoading, setIsLoading] = useState(false);
  const [hasError, setHasError] = useState(false);

  const observerRef = (useRef < IntersectionObserver) | (null > null);
  const targetRef = useRef < HTMLDivElement > null;

  const fetchNextPage = async () => {
    if (isLoading || !pagination.hasNext) return;

    setIsLoading(true);

    try {
      const newData = await fetchMore({
        limit: pagination.limit,
        cursor: pagination.nextCursor,
      });

      setItems((prev) => [...prev, ...newData.items]);
      setPagination(newData.pagination);
    } catch (error) {
      console.error(error);
      setHasError(true);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry.isIntersecting && pagination.hasNext && !isLoading) {
          fetchNextPage();
        }
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 1,
      }
    );

    if (targetRef.current) {
      observerRef.current.observe(targetRef.current);
    }

    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [pagination.nextCursor, isLoading]);

  return {
    items,
    pagination,
    isLoading,
    hasError,
    targetRef,
  };
}

무한 스크롤 컴포넌트 사용 예시

export function MomentList({ type, initialMoments, userId }: MomentListProps) {
  const { items, isLoading, hasError, targetRef } =
    useInfiniteScroll <
    MomentItemType >
    {
      initialData: initialMoments,
      fetchMore: async ({ limit, cursor }) => {
        const newData = await getMoments({
          limit,
          cursor,
          type,
          userId,
        });
        return newData;
      },
    };

  return (
    <div className="flex flex-col gap-2">
      {items.map((moment) => (
        <MomentItem moment={moment} key={moment.id} />
      ))}
      <div ref={targetRef} />
      {isLoading && <LoadingSpinner className="h-24" />}
      {hasError && <FullScreenMessage message="데이터 로드에 실패했습니다." />}
    </div>
  );
}

Categories:

Updated:

Leave a comment