프론트엔드/React

useIntersectionObserverRef 커스텀 훅 만들기

정현우12 2023. 2. 16. 11:17

IntersectionObserver는 무한 스크롤 or 이미지 lazy loading 등 다양한 곳에서 활용할 수 있다.

react, typescript 환경에서 쉽게 IntersectionObserver를 활용할 수 있는 커스텀 훅을 제작했다.

Typing

interface UseIntersectionObserverRefProps {
  readonly callback: IntersectionObserverCallback
  readonly options?: IntersectionObserverInit
  readonly type?: 'callback' | 'ref'
}

export const useIntersectionObserverRef = <T extends HTMLElement>({
  callback,
  options = { root: null, rootMargin: '0px', threshold: 0 },
  type = 'ref',
}: UseIntersectionObserverRefProps): RefCallback<T> | RefObject<T> => {
    /// 생략
}

props 는 해당 DOM을 교차할 때 실행되는 callbackobserver의 기준 컨테이너, 교차 비율 등을 커스텀할 수 있는 options, refrefCallback 으로 받을지 refObject로 받을지 결정하는 type으로 구성했다.

함수 타입은 제네릭을 활용해 <T extends HTMLElement> HTMLElement의 타입별로 활용할 수 있게 했다. 리턴 타입은 RefCallback<T> | RefObject<T>로 줘서 type에 따라 ref 콜백 or ref 오브젝트를 주도록 했다.

콜백 포장하기

const callbackOnlyIntersecting: IntersectionObserverCallback = (entries, observer) => {
    const isIntersecting = entries.map(entry => entry.isIntersecting).reduce((acc, cur) => acc && cur, true)
    if (isIntersecting) {
      callback(entries, observer)
    }
  }

IntersectionObserver는 생성될 때 callback을 실행한다. 그래서 생성될 때 실행을 막기 위해서 콜백을 포장했다. 콜백에 인자로 전달되는 entries의 각 원소는 실제로 화면에서 교차하고 있는지 알려주는 isIntersecting이라는 프로퍼티를 가진다. 이 프로퍼티를 활용해서 실제로 관찰중인 DOM이 교차할 때만 콜백이 실행되도록 했다.

Observer로 DOM 관찰시키기

단일 타겟

하나의 DOM 노드만 관찰할 필요가 있는 경우(ex: 무한 스크롤) 에는 type='ref' (default) 로 훅을 호출하여 사용하면 된다.

const observerRef = useRef<IntersectionObserver>(new IntersectionObserver(callbackOnlyIntersecting, options))
const elementRef = useRef<T>(null)

useEffect(() => {
if (type === 'callback' || !elementRef.current || !observerRef.current) {
  return
}
observerRef.current.observe(elementRef.current)
return () => observerRef.current.disconnect()
}, [elementRef, observerRef])
return elementRef

IntersectionObserver를 생성하고 dom에 연결된 ref를 활용해 해당 dom을 관찰시킨다. 언마운트시 관찰을 해제한다.

생성된 elementRef를 리턴한다.

배열 타겟

여러개의 DOM 노드를 관찰할 필요가 있는 경우(ex: 이미지 레이지로딩)에는 type='callback'로 훅을 호출한다.

if (type === 'callback') {
  const refCallback = (element: T) => {
      if (element && observerRef.current) {
      observerRef.current.observe(element)
      }
  }
return refCallback
}

리액트에서 DOM 노드에 ref에 콜백함수를 전달해서 IntersectionObserver를 사용할 수 있다. 그 경우에 refCallback: (el: T extends HTMLElement) => void 를 DOM의 ref로 주면 된다. 이를 이용해서 여러개의 노드를 타겟으로 할 떄 ref를 많이 만들 필요 없이 콜백 한개로 처리할 수 있다.

전체 코드

import { Ref, RefCallback, RefObject, useEffect, useRef } from 'react'

interface UseIntersectionObserverRefProps {
  readonly callback: IntersectionObserverCallback
  readonly options?: IntersectionObserverInit
  readonly type?: 'callback' | 'ref'
}

export const useIntersectionObserverRef = <T extends HTMLElement>({
  callback,
  options = { root: null, rootMargin: '0px', threshold: 0 },
  type = 'ref',
}: UseIntersectionObserverRefProps): RefCallback<T> | RefObject<T> => {
  const callbackOnlyIntersecting: IntersectionObserverCallback = (entries, observer) => {
    const isIntersecting = entries.map(entry => entry.isIntersecting).reduce((acc, cur) => acc && cur, true)
    if (isIntersecting) {
      callback(entries, observer)
    }
  }
  const observerRef = useRef<IntersectionObserver>(new IntersectionObserver(callbackOnlyIntersecting, options))
  const elementRef = useRef<T>(null)

  useEffect(() => {
    if (type === 'callback' || !elementRef.current || !observerRef.current) {
      return
    }
    observerRef.current.observe(elementRef.current)
    return () => observerRef.current.disconnect()
  }, [elementRef, observerRef])

  if (type === 'callback') {
    const refCallback = (element: T) => {
      if (element && observerRef.current) {
        observerRef.current.observe(element)
      }
    }
    return refCallback
  }

  return elementRef
}

사용법

useIntersectionObserverRef훅을 활용해 간단한 무한스크롤을 구현한 예시이다.

import { useIntersectionObserverRef } from '@/hooks/useIntersectionObserver'
import { useState } from 'react'

function App() {
  const [items, setItems] = useState<any[]>([])
  const infiniteScrollRef = useIntersectionObserverRef<HTMLDivElement>({
    callback: () => {
      setItems(prev => [...prev, ...new Array(5).fill(0)])
    },
  })
  return (
    <div className="App">
      {items.map((item, idx) => (
        <div key={idx} style={{ width: '100%', height: '200px', background: 'blue', border: '1px solid white' }} />
      ))}
      <div ref={infiniteScrollRef} />
    </div>
  )
}

export default App

최하단에 위치한 divref를 줘서 해당 div가 화면에 교차할 때마다 setItems를 호출한다. 즉, 스크롤해서 맨 밑으로 내릴 때마다 callback이 호출되어서 무한스크롤로 아이템들이 나타난다. 실제 활용시에는 data fetch후에 setItems를 해주는 식으로 활용할 수 있다.