Front-end/React

[React] hooks로 모듈화하여 변경에 유연한 컴포넌트 만들기

helloyukyung 2023. 2. 11. 18:15

프로젝트 내에 한 리스트 페이지를 구현하면서 무한 스크롤 페이징 기능을 만들었다.
react-query의 useInfiniteQuery와 IntersectionObserver를 사용하여 구현해 보았는데,

다른 페이지에서 반복적으로 사용될 수 있는 로직이 있어 custom hooks으로 분리하여 리팩토링을 해주었다.

이를 블로그로 정리해보고자 한다.

무한 스크롤 페이징의 구현

infinite scroll

무한 스크롤 페이징의 구현방식은 다음과 같다.

  1. useInfiniteQuery를 통해 리스트 데이터를 일부 호출하여 보여준다.
  2. IntersectionObserver를 통해 view에서 지금까지 호출된 데이터의 리스트가 다 보였을 때를 target으로 인지하고
  3. target이 보였을 때 && list에 다음 페이지 데이터가 있을 때 다음 페이지 데이터를 호출해 준다.

Before (custom Hooks 적용 전)

function useBooks() {
  const books = useGetBooksQuery()
  const targetRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!targetRef || !books.hasNextPage) return
    if (targetRef.current) {
      const observer = new IntersectionObserver(async (entries, observer) => {
        if (entries[0].isIntersecting) {
          await estimates.fetchNextPage()
        }
      })
      observer.observe(targetRef.current)
    }
  }, [])

  return {books, targetRef}
}

export default useBooks
function Books() {
  const {books, targetRef} = useBooks()

  return (
    <>
     // ...
      <BookList data={books} targetRef={targetRef} />
    </>
  )
}

export default Books

books의 데이터는 변하지 않지만, UI는 언제든지 바뀔 가능성이 있으므로, 데이터와 UI를 분리하여 구성해주고자 하였다.

 

books에 대한 데이터를 추상화하여 useBooks라는 hooks로 표현해 주었고,

BooksuseBooks에서 반환된 값을 어떻게 보여주는지 정의해 주었다. 

위와 코드 구성은 디자인이 다르지만 같은 books 데이터를 필요로 할 때 useBooks hooks를 가져다 사용할 수 있게 해준다.

 

변경에 유연해지려면 각 모듈이 한 가지 일만 하는 것이 중요하다.
하지만, useBooks에는 books데이터를 get 해오는 기능뿐만 아니라, infiniteScroll의 기능까지 함께 들어있었다.

다른 페이지에서 infiniteScroll기능을 구현해야 할 때 중복이 발생할 것이다. 

 

동일하게 사용될 수 있는 infiniteScroll 코드를 custom hooks으로 빼보자.
+ BookList 컴포넌트도 전역적으로 사용할 수 있는 List로 교체해 줄 것이다. 

useInfiniteScroll

export interface UseInfiniteScrollProps
  extends Pick<UseInfiniteQueryResult<any>, 'isFetchingNextPage' | 'fetchNextPage' | 'hasNextPage'> {}

function useInfiniteScroll({hasNextPage, isFetchingNextPage, fetchNextPage}: UseInfiniteScrollProps) {
  const [target, setTarget] = useState<HTMLDivElement | null>(null)

  useEffect(() => {
    if (!hasNextPage || isFetchingNextPage) return undefined

    let observer: IntersectionObserver
    if (target) {
      observer = new IntersectionObserver(async ([entry]) => {
        if (entry.isIntersecting) {
          await fetchNextPage()
        }
      })
      observer.observe(target)
    }
    return () => observer && observer.disconnect()
  }, [target, hasNextPage, isFetchingNextPage])

  return {setTarget}
}

export default useInfiniteScroll

infiniteScroll을 담당하는 로직만 useInfinteScroll hooks으로 분리해 주었다.

import {InfiniteData} from 'react-query'

export interface IListProps extends IUseInfiniteScrollProps {
  name: string
  isSuccess?: boolean
  isLoading?: boolean
  data?: InfiniteData<any>
  renderItem: (item: any) => ReactNode
}

function List({
  name,
  renderItem,
  data,
  isLoading,
  isSuccess,
  hasNextPage,
  isFetchingNextPage,
  fetchNextPage
}: IListProps) {
  const {setTarget} = useInfiniteScroll({hasNextPage, isFetchingNextPage, fetchNextPage})

  if (isSuccess && !data) return <StyledList>데이터가 없습니다(</StyledList>
  if (isLoading || !data)
    return (
      <StyledList>
        <LoadingSkeleton />
      </StyledList>
    )

  return (
    <StyledList>
      {data.pages?.map((page) =>
        page.data.map((item: any, index: number) =>
          renderItem(item) &&
            <ListItem key={`${index}_${name}`}>{renderItem(item)}</ListItem>
        )
      )}
      {isFetchingNextPage ? <LoadingSkeleton /> : <div ref={setTarget} />}
    </StyledList>
  )
}

export default List

그리고 무한 스크롤과 같이 사용될 List컴포넌트를 만들고 여기서 useInfiniteScroll을 사용해 주었다.

function Books() {
  const {books} = useBooks()

  return (
    <>
     // ...
     <List {...books} name="books" renderItem={(item) => <ListItem {...item} />} />
    </>
  )
}

export default Books

useBooks는 데이터를 패치하는 로직만 남게 될 것이고, Books 컴포넌트는 다음과 같이 바뀌게 된다.

 


 

마무리

리팩토링 하는 과정에서 어떻게 하면 유연하고 변경에 대응 가능한 컴포넌트를 만들 수 있는지 고민을 많이 했던 것 같다. 
이전에는 그냥 어떤 기준 없이 중복이니까 분리하거나, 단순히 크다는 이유로 분리하고는 했다.
아래와 같이 기준을 세우고 항상 기준 내에 부합하는지, 어떻게 하면 좀 더 변경에 유연한 컴포넌트를 만들 수 있을지,
항상 인지하면서 코드를 개발해야겠다.


1. headless 기반의 추상화(변하는 것 vs 상대적으로 변하지 않는 것)
2. 한 가지 역할만 하기 (또는 한가지 역할만 하는 컴포넌트의 조합으로 구성하기)

3. 도메인 분리하기 (도메인을 포함하는 컴포넌트와 그렇지 않은 컴포넌트 분리하기)


참고 :

https://www.youtube.com/watch?v=fR8tsJ2r7Eg&t=497s