Published on

React Infinite Scroll 구현하기

Authors
  • avatar
    Name
    Hyo814
    Twitter

무한 스크롤은 많은 웹 애플리케이션에서 데이터 로딩 경험을 개선하기 위해 자주 사용되는 패턴입니다. React에서 무한 스크롤을 구현하는 방법은 여러 가지가 있는데, 이번 글에서는 @tanstack/react-queryuseInfiniteQueryuseQuery, 그리고 swruseSWR을 사용하여 무한 스크롤을 구현하는 방법을 비교해보려고 합니다.


1. @tanstack/react-query의 useInfiniteQuery 사용 예제

import React, { useEffect, useRef } from 'react'
import { useInfiniteQuery, QueryFunctionContext } from '@tanstack/react-query'

interface Photo {
  id: number
  title: string
  url: string
  thumbnailUrl: string
}

const fetchPhotos = async ({ pageParam = 1 }: QueryFunctionContext): Promise<Photo[]> => {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/photos?_page=${pageParam}&_limit=10`
  )
  return res.json()
}

const InfiniteScroll: React.FC = () => {
  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery<
    Photo[],
    Error
  >({
    queryKey: ['photos'],
    queryFn: fetchPhotos,
    getNextPageParam: (lastPage, allPages) => (lastPage.length ? allPages.length + 1 : undefined),
  })

  const observerRef = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage()
      }
    })

    if (observerRef.current) {
      observer.observe(observerRef.current)
    }

    return () => {
      if (observerRef.current) {
        observer.unobserve(observerRef.current)
      }
    }
  }, [fetchNextPage, hasNextPage])

  if (error) return <div>Failed to load</div>

  return (
    <div>
      <h1>Infinite Scroll</h1>
      <div>
        {data?.pages.map((page, i) => (
          <React.Fragment key={i}>
            {page.map((photo: Photo) => (
              <div key={photo.id}>
                <img src={photo.thumbnailUrl} alt={photo.title} />
                <p>{photo.title}</p>
              </div>
            ))}
          </React.Fragment>
        ))}
      </div>
      {isFetchingNextPage && <p>Loading more...</p>}
      <div ref={observerRef} style={{ height: '1px' }}></div>
    </div>
  )
}

export default InfiniteScroll

장점

  • 페이지네이션 처리 내장: useInfiniteQuery는 페이지네이션 처리가 내장되어 있어, 다음 페이지 데이터를 가져오는 것이 매우 용이합니다.
  • 간단한 설정: getNextPageParam 함수를 통해 다음 페이지 매개변수를 자동으로 설정할 수 있어 무한 스크롤 로직을 간단히 구현할 수 있습니다.
  • 로딩 상태 관리: isFetchingNextPage와 같은 상태를 통해 쉽게 로딩 상태를 관리할 수 있습니다.

단점

  • 설정 복잡성: react-query의 개념을 잘 이해해야만 설정을 제대로 할 수 있습니다.

2. @tanstack/react-query의 useQuery 사용 예제

import React, { useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'

interface Photo {
  id: number
  title: string
  url: string
  thumbnailUrl: string
}

const fetchPhotos = async (page: number): Promise<Photo[]> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`)
  return res.json()
}

const InfiniteScroll: React.FC = () => {
  const [page, setPage] = useState<number>(1)
  const [photos, setPhotos] = useState<Photo[]>([])
  const observerRef = useRef<HTMLDivElement | null>(null)

  const { data, error, isLoading } = useQuery(['photos', page], () => fetchPhotos(page), {
    keepPreviousData: true,
  })

  useEffect(() => {
    if (data) {
      setPhotos((prevPhotos) => [...prevPhotos, ...data])
    }
  }, [data])

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        setPage((prevPage) => prevPage + 1)
      }
    })

    if (observerRef.current) {
      observer.observe(observerRef.current)
    }

    return () => {
      if (observerRef.current) {
        observer.unobserve(observerRef.current)
      }
    }
  }, [])

  if (error) return <div>Failed to load</div>

  return (
    <div>
      <h1>Infinite Scroll</h1>
      <div>
        {photos.map((photo) => (
          <div key={photo.id}>
            <img src={photo.thumbnailUrl} alt={photo.title} />
            <p>{photo.title}</p>
          </div>
        ))}
      </div>
      {isLoading && <p>Loading...</p>}
      <div ref={observerRef} style={{ height: '1px' }}></div>
    </div>
  )
}

export default InfiniteScroll

장점

  • 간단한 로직 구현: 간단하게 페이지네이션 로직을 구현할 수 있습니다.
  • 상태 관리: useState를 통해 상태를 관리할 수 있어 직관적입니다.

단점

  • 수동 설정 필요: 무한 스크롤을 위한 로직을 수동으로 처리해야 합니다.
  • 코드 작성 증가: useInfiniteQuery에 비해 더 많은 코드 작성이 필요합니다.

3. SWR의 useSWR 사용 예제

import React, { useEffect, useRef, useState } from 'react'
import useSWR from 'swr'

interface Photo {
  id: number
  title: string
  url: string
  thumbnailUrl: string
}

const fetcher = (url: string) => fetch(url).then((res) => res.json())

const InfiniteScroll: React.FC = () => {
  const [page, setPage] = useState<number>(1)
  const [photos, setPhotos] = useState<Photo[]>([])
  const observerRef = useRef<HTMLDivElement | null>(null)

  const { data, error } = useSWR<Photo[]>(
    `https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`,
    fetcher
  )

  useEffect(() => {
    if (data) {
      setPhotos((prevPhotos) => [...prevPhotos, ...data])
    }
  }, [data])

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        setPage((prevPage) => prevPage + 1)
      }
    })

    if (observerRef.current) {
      observer.observe(observerRef.current)
    }

    return () => {
      if (observerRef.current) {
        observer.unobserve(observerRef.current)
      }
    }
  }, [])

  if (error) return <div>Failed to load</div>

  return (
    <div>
      <h1>Infinite Scroll</h1>
      <div>
        {photos.map((photo) => (
          <div key={photo.id}>
            <img src={photo.thumbnailUrl} alt={photo.title} />
            <p>{photo.title}</p>
          </div>
        ))}
      </div>
      <div ref={observerRef} style={{ height: '1px' }}></div>
    </div>
  )
}

export default InfiniteScroll

장점

  • 간단한 설정: SWR은 데이터 가져오기와 캐싱을 자동으로 관리하여 설정이 간단하고 사용하기 쉽습니다.
  • 자동화된 데이터 관리: 데이터를 효율적으로 가져오고 캐싱합니다.

단점

  • 무한 스크롤 추가 로직 필요: 무한 스크롤을 구현하기 위해 추가적인 로직을 직접 구현해야 합니다.
  • 내장 옵션 부족: react-query처럼 다음 페이지 데이터를 명확하게 처리하기 위한 내장 옵션이 부족합니다.