ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Intersection Observer 에서 onIntersect 안에 있는 함수가 useState 상태를 인식하지 못하는 이유와 해결법
    concept/React, Redux, RN 2024. 12. 26. 17:20

    무한 스크롤을 구현하기 위해서 useRef와 Intersection Observer 를 활용해,

    화면 하단의 Ref 가 뷰포트에 나타나면 그 때 서버로 데이터를 더 요청하는 로직으로 구현했다.

     

    React, Next.js 프로젝트로 구현한다면 커스텀 훅으로 useObserver 를 만들어 두는 것이 첫번째 할 일.

     

    useObserver hook 만들기

    import { useEffect } from 'react'
    
    type UseObserverProps = {
      target: React.RefObject<HTMLElement> // 감지할 대상
      onIntersect: IntersectionObserverCallback // 감지 시 실행할 callback 함수
      root?: Element | null // 교차할 부모 요소
      rootMargin?: string // root와 target이 감지하는 여백의 거리
      threshold?: number // 임계점
    }
    
    export const useObserver = ({
      target,
      onIntersect,
      root = null,
      rootMargin = '0px',
      threshold = 1.0,
    }: UseObserverProps) => {
      useEffect(() => {
        let observer: any
    
        // 넘어오는 element가 있어야 observer를 생성할 수 있도록 한다.
        if (target && target.current) {
          // callback의 인자로 들어오는 entry는 기본적으로 순환자이기 때문에
          // 복잡한 로직을 필요로 할때가 많다.
          // callback을 선언하는 곳에서 로직을 짜서 통째로 넘기도록 하겠다.
          observer = new IntersectionObserver(onIntersect, {
            root,
            rootMargin,
            threshold,
          })
          // 실제 Element가 들어있는 current 관측을 시작한다.
          observer.observe(target.current)
        }
    
        // observer를 사용하는 컴포넌트가 해제되면 observer 역시 꺼 주자.
        return () => observer && observer.disconnect()
      }, [target, rootMargin, threshold])
    }

     

    다음과 같이 useObserver 커스텀 훅을 제작해 놓고 이제 원하는 페이지에서 이 훅을 갖다 써야 할 차례다.

     

     

    그래서 상품 데이터를 불러오는 tanstack query useQuery 를 호출할 때, 현재 페이지 상태를 key로 잡아 페이지가 변경될 때마다 데이터를 더 가지고 오도록 다음과 같이 코드를 작성했다.

     const { data: products } = useProductList({
        page,
        perPage: 10,
        keyword,
        sort,
      })

     

     

    그리고 intersection observer 를 활용하기 위해 다음과 같이 코드를 작성했다.

     

      // 버그가 발생하는 코드
      
      const [page, setPage] = useState<number>(1)
      
      const handleIntersect = () => {
        if (page < maxPage) {
          setPage(prevPage => prevPage + 1)
        }
      }
    
      useObserver({
        target: observerTarget,
        onIntersect: handleIntersect,
      })

     

    설정한 Ref 가 화면 뷰포트에 들어오게 되면 위에서 만든 useObserver 훅이 실행되고, 그 훅을 통해 onIntersect 에 할당된 함수인 handleIntersect 가 실행된다.

    그래서 handleIntersect 함수에 page state 를 새롭게 업데이트 하는 코드를 작성했으며, 조건을 달아 현재 page 가 서버에서 내려받은 maxPage 보다 작을 경우에만 요청하도록 해, 데이터가 더이상 없다면 서버로 부터 데이터를 요청하지 않는 조건을 추가했다.

     

    하지만 이 코드는 정상적으로 작동되지 않는다.

     

    위 코드에서 page 는 useState로 정의되었는데 page 상태가 업데이트되어도 handleIntersect 함수는 page 상태가 그대로 1로 출력되고 있었기 때문이다. 그 이유를 조금 더 찾아보니

    Intersection Observer에서 onIntersect 안에 있는 함수가 useState 상태를 인식하지 못하는 이유는
    onIntersect가 의존성 배열 없이 초기 렌더링 시 생성된 값을 계속 참조하기 때문이라고 한다. 

     

    조금 더 자세히 풀어서 설명하자면,

    onIntersect는 IntersectionObserver 콜백 함수로, 보통 컴포넌트가 초기 렌더링될 때 정의됩니다. React 상태는 렌더링될 때마다 갱신되는데, onIntersect 안에서 사용되는 상태 값은 정의 시점의 클로저에 갇히게 됩니다. 결과적으로 이후 상태가 업데이트되어도 onIntersect 내부에서는 오래된 상태를 참조하게 됩니다.

     

    결국 처음 컴포넌트가 렌더링 되었을 때만 값을 새롭게 가지고 오기 때문이라는 것...

     

    그래서 해결한 방법은 다음과 같다.

    useRef로도 상태를 관리할 수 있다는 사실을 아는가?

     

    useRef 로 상태를 관리하는 경우는 다음과 같은 상황들이 있다.

     

    useRef로 상태를 관리하는 경우

     

    1. 상태 변경 시 렌더링 방지

    : 컴포넌트 렌더링을 트리거 하지 않으면서도 값을 유지

     

    2. 클로저 문제 해결

    : 항상 최신 값을 저장하고 싶을 때,

     

    3. DOM 노드 또는 타이머 관리

    : 컴포넌트가 리렌더링 되더라도 유지되는 mutable 객체이기 때문에 타이머, 이벤트 리스너, 외부 API, DOM 노드를 제어할 때 유용

     

    이 중 2번 상황이 지금 내가 맞닥뜨린 상황이기 때문에 useRef 를 활용해서 page 상태를 관리해보자.

     

    // 정상 작동 코드
    
      const pageRef = useRef(page)
      const maxPageRef = useRef<number | null | undefined>(null)
      const observerTarget = useRef<HTMLDivElement | null>(null)
    
      const handleIntersect = () => {
        if (maxPageRef.current && pageRef.current < maxPageRef.current) {
          setPage(prevPage => prevPage + 1)
        }
      }
    
      useObserver({
        target: observerTarget,
        onIntersect: handleIntersect,
      })
    
      useEffect(() => {
        pageRef.current = page
        maxPageRef.current = products?.payload.pageInfo.maxPage
      }, [products, page])

     

     

    이와 같이 page를 ref로 관리하고 해당 ref의 current 값을 가져와서 비교한다면 정상적으로 작동한다.

    댓글

Designed by Tistory.