React Query?

React Query란 간단하게 말하자면 데이터 패칭 라이브러리이다. 하지만 데이터를 패칭하는 기능만 지원하는게 아니라 Devtools, 캐싱, 서버 상태 동기화 및 업데이트 등 많은 기능을 지원한다. 사용은 Apollo와 비슷하다. 똑같이 데이터가 필요한 곳에서 useQuery를 사용하며 아까 언급했듯이 서버 데이터를 캐싱한다. 또한 A 라는 데이터를 사용하는 컴포넌트가 여러개일 경우 각 컴포넌트들에서 같은 쿼리 키를 가진 useQuery를 사용함으로써 한 번의 데이터 패칭으로 모두 A 데이터를 사용할 수 있게 된다. 패칭하여 데이터를 스토어에 저장한 후 전역에서 해당 데이터를 사용할 수 있게 해주는 redux와 비슷한 것을 알 수 있으며 redux의 액션, 리듀서 등의 보일러플레이트를 작성하지 않고 서버 데이터를 관리할 수 있는 또 다른 대안이 될 것이다.
Github 스타도 오늘 기준 20.8k로 많은 인기를 끌고 있으며 개발이 활발하게 이루어지고 있다. 공식 문서도 정리가 정말 잘 되어 있으니 관심이 있다면 한번 사용해보는 걸 추천한다.

SSR(Server Side Rendering)

React Query는 SSR을 두 가지 방식으로 구현할 수 있다. initialData를 주는 방법과 Hydration을 하는 방법이다. 여기서는 Hydration을 통해 SSR을 구현한다. initialData를 통한 방법이 데이터를 명시해주기만 하면 되기 때문에 훨씬 간단하긴 하지만 문제가 있다. 만약 여러 컴포넌트에서 해당 데이터를 SSR을 통해 사용자에게 보여준다고 한다면, 모든 컴포넌트에 initialData를 넘겨줘야 하기 때문에 넘겨줘야 하는 대상 컴포넌트가 컴포넌트 트리 상에서 depth가 깊이 있는 컴포넌트일 때는 관리도 그렇고 비효율적이다. 반면 Hydration을 통한 방법은 SSR을 할 때 원하는 쿼리를 prefetch하고 해당 쿼리를 사용하는 컴포넌트에서는 동일한 키로 useQuery 훅을 호출하기만 하면 된다.

 

이 프로젝트에서는 PokeApi를 사용해서 간단하게 포켓몬들의 이름을 나열해보도록 하겠다.

먼저, Next.js 프로젝트의 _app에서 queryClient를 내려준다.

 

// _app/index.tsx

import React from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { NextComponentType } from "next"
import { AppContext, AppInitialProps, AppProps } from "next/app"
import { Hydrate } from 'react-query/hydration'
import { ReactQueryDevtools } from 'react-query/devtools'

const MyApp: NextComponentType<AppContext, AppInitialProps, AppProps> = ({ Component, pageProps }) => {
  const queryClientRef = React.useRef<QueryClient>()
  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient()
  }

  return <>
    <QueryClientProvider client={queryClientRef.current}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
      <ReactQueryDevtools />
    </QueryClientProvider>
  </>
}

MyApp.getInitialProps = async ({ Component, ctx }: AppContext): Promise<AppInitialProps> => {
  let pageProps = {}

  if (Component.getInitialProps) {
    pageProps = await Component.getInitialProps(ctx)
  }

  return { pageProps }
}

export default MyApp

 

dehydratedState/poke 경로의 getServerSideProps에서 반환해준 값을 사용할 것이다. pages폴더에 poke파일을 만들어주자. 여기서 무한스크롤에 사용되는 쿼리를 prefetch하고, 그것을 dehydrate하여 넘겨줄 것이다.

 

// pages/poke.tsx

import Pokemon from '../components/Pokemon'
import { getPoke } from '../api'
import { QueryClient } from 'react-query'
import { dehydrate } from 'react-query/hydration'

const Poke = () => {
  return (
    <Pokemon />
  )
}

export async function getServerSideProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchInfiniteQuery('poke', () => getPoke(), { staleTime: 1000 })

  return {
    props: {
      dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))),
    }
  }
}

export default Poke 

 

dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient)))

 

위와 같이 dehydrate한 것을 stringify했다가 다시 parse하는 것은 InfiniteQuery를 사용할 때 발생하는 이슈이다. 그냥 dehydrate한 상태로 넘겨주면 다음과 같은 오류가 난다.

 

 

InfiniteQuery에서 맨 처음 페이지에 해당하는 데이터의 pageParamundefined로 설정되기 때문에 hydration과정에서 직렬화가 되지 않아서 저렇게 해주었다. 더 궁금하다면 이슈를 참고해보면 좋을 것 같다.

 

여기까지 했다면 getServerSideProps에서 props로 넘겨준 dehydratedState_app에서 받아 Hydration로 내려줄 것이다. 이제 컴포넌트에서 prefetch에서 사용된 쿼리와 같은 키인 'poke' 사용하여 useInfiniteQuery 훅을 호출하면 된다.

먼저 api를 통해 포켓몬 데이터를 받아오는 함수를 만들어주자. 루트 디렉토리에서 api폴더를 하나 만들고 그 안에 index.ts파일을 만든다.

 

// api/index.ts

import axios from 'axios'

export const getPoke = async (offset: number = 0) => {
  const { data } = await axios.get(
    `https://pokeapi.co/api/v2/pokemon?limit=20&offset=${offset}`
  );
  return data;
} 

 

무한 스크롤

components폴더에 Pokemon.tsx파일을 만들어주자. 위에서도 언급했지만 React Query에서 무한 스크롤을 구현할 때는 주로 useInfiniteQuery를 사용한다. 첫 번째 인자로 쿼리 키를 주고, 두 번째는 쿼리 함수, 마지막으로 쿼리에 적용할 옵션들을 넘겨준다. 여기서는 쿼리 키는 'poke'이고 쿼리 함수는 위에서 만든 getPoke함수가 되겠다. 그리고 getNextPageParam라는 옵션에서 이전 페이지의 데이터를 통해 무한 스크롤을 언제 끝낼지 결정할 수 있다. 어떠한 값을 리턴할 경우 useInfiniteQuery가 리턴하는 result 객체의 hasNextPagetrue값이 되고, undefined를 리턴할 경우 false가 된다.

 

// components/Pokemon.tsx

import React, { useEffect, useState } from 'react'
import { useInfiniteQuery, useQuery } from 'react-query'
import { getPoke } from '../api'

const Pokemon = () => {
  const { data, fetchNextPage } = useInfiniteQuery('poke', 
    ({ pageParam = '' }) => getPoke(pageParam), 
    {
      getNextPageParam: (lastPage) => {
        const lastOffset = lastPage.results[lastPage.results.length - 1].url.split('/')[6]
        if (lastOffset > 1118) {
          return undefined
        }
        return lastOffset
      },
      staleTime: 1000,
    }
  )

  return (
    <>
      <ul>
        {data.pages.map((page) => (
          page.results.map((poke) => (
            <li key={poke.name} style={{ padding: '20px', fontWeight: 'bold'}}>{poke.name}</li>
          ))
        ))}
      </ul>
      <button onClick={() => fetchNextPage()}>Load More</button>
    </>
  )
}

 

위와 같이 getNextPageParam에서 lastPage를 인자로 받아와 다음 페이지를 로드할지 결정한다. staleTime은 React Query가 관리하는 캐시에서 해당 쿼리를 어느 시점에 fresh상태에서 stale상태로 바꿀지 결정하는 옵션이다. 이에 대한 개념은 공식 문서에 자세히 나와있다.

여기까지만 해도 잘 동작한다. 하지만 Load More 버튼을 눌러야 fetchNextPage 함수가 실행되어 다음 페이지를 불러온다. fetchNextPage에 대한 내용은 이따가 좀 더 자세히 다루겠다. 어쨋든 이건 무한 스크롤이라고 볼 수 없다. 이제 IntersectionObserver를 통해 무한 스크롤을 구현해보자.

 

여기서는 공식 문서대로 useIntersectionObserver 훅을 만들어 사용할 것이다. hooks폴더를 하나 만들고 useIntersectionObserver.ts파일을 만들어주자.

 

// hooks/useIntersectionObserver.ts

import React from 'react'

export default function useIntersectionObserver({
  root,
  target,
  onIntersect,
  threshold = 1.0,
  rootMargin = '0px',
  enabled = true,
}) {
  React.useEffect(() => {
    if (!enabled) {
      return
    }

    const observer = new IntersectionObserver(
      entries =>
        entries.forEach(entry => entry.isIntersecting && onIntersect()),
      {
        root: root && root.current,
        rootMargin,
        threshold,
      }
    )

    const el = target && target.current

    if (!el) {
      return
    }

    observer.observe(el)

    return () => {
      observer.unobserve(el)
    }
  }, [target, enabled, root, threshold, rootMargin, onIntersect])
}

 

그리고 Pokemon 컴포넌트에서 해당 훅을 사용한다.

 

// components/Pokemon.tsx

import React, { useEffect, useState } from 'react'
import { useInfiniteQuery, useQuery } from 'react-query'
import { getPoke } from '../api'
import useIntersectionObserver from '../hooks/useIntersectionObserver'

const Pokemon = () => {
  const { data, hasNextPage, fetchNextPage } = useInfiniteQuery('poke', 
    ({ pageParam = '' }) => getPoke(pageParam), 
    {
      getNextPageParam: (lastPage) => {
        const lastOffset = lastPage.results[lastPage.results.length - 1].url.split('/')[6]
        if (lastOffset > 1118) {
          return undefined
        }
        return lastOffset
      },
      staleTime: 3000,
    }
  )

  const loadMoreButtonRef = React.useRef()

  useIntersectionObserver({
    root: null,
    target: loadMoreButtonRef,
    onIntersect: fetchNextPage,
    enabled: hasNextPage,
  })

  return (
    <>
      <ul>
        {data.pages.map((page) => (
          page.results.map((poke) => (
            <li key={poke.name} style={{ padding: '20px', fontWeight: 'bold'}}>{poke.name}</li>
          ))
        ))}
      </ul>
      <button onClick={() => fetchNextPage()}>Load More</button>
      <div ref={loadMoreButtonRef}/>
    </>
  )
}

 

이렇게 하면 SSR과 무한 스크롤까지 잘 동작할 것이다. 

 

 

옆의 개발자 도구에서 네트워크 창을 보면 데이터 요청없이 SSR로 포켓몬 이름을 잘 출력하는 것을 볼 수 있다.

그리고 스크롤을 좀 내리면 다음과 같은 결과가 될 것이다.

 

 

네트워크 요청과 함께 다음 페이지의 데이터를 가져온다. useInfiniteQuery 훅을 사용하여 간단하게 무한 스크롤을 구현했다. fetchNextPage를 실행시키면 getNextPageParam에서 다음 pageParam을 받아서 쿼리 함수에 넘겨주게 된다. 여기서는 SSR을 하고, 첫 스크롤일 경우 pageParam은 20이 되고 getPoke 함수에 20이 넘어가게 된다. 그렇게 받아온 데이터는 useInfiniteQuery 훅이 리턴하는 result 객체의 data에 차곡차곡 쌓이게 된다. 이 데이터는 위 사진에서 왼쪽 하단에 보이는 Devtools를 통해서도 확인이 가능하다.

 

 

 

참고


생강강

,