
아래 글은 tanstack query 공식 문서(5.80.7)를 기반으로 쓰여졌습니다. 대부분의 예제와 설명은 tanstack query 문서에서 가져왔습니다.
Tanstack Query 는 CSR 로 기본적으로 작동하기 때문에, Next.js 에서 SSR 의 기능을 살려서 작동하기 위해서는 prefetching 과 hydration 을 활용해야 한다.
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
// include pending queries in dehydration
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
})
}
let browserQueryClient: QueryClient | undefined = undefined
export function getQueryClient() {
if (isServer) {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}
Query Client 를 받아오는 작업에 차이가 발생한다.
서버: 항상 새로운 Query Client 를 만든다.
브라우저:browserQueryClient는 React Component 밖에 존재하기 때문에 한번만 만든다.
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from '@/app/get-query-client'
export default function Providers({
children
}: {
children: React.ReactNode
}) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
QueryClientProvider 를 통해 queryClient 를 전달한다.
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
layout 에 만든 Providers 컴포넌트를 추가해 children 에서 queryClient 를 사용할 수 있도록 한다.
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { pokemonOptions } from '@/app/pokemon'
export function PokemonInfo() {
const { data } = useSuspenseQuery({
queryKey: ['pokemon'],
queryFn: async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon/25')
return response.json()
},
})
return (
<div>
<figure>
<img src={data.sprites.front_shiny} height={200} alt={data.name} />
<h2>I'm {data.name}</h2>
</figure>
</div>
)
}
Tanstack Query 를 Server Component 에서 사용하게 되면 달라지는 점은, Fetch 작업을 서버에서 처리한다는 점이다.
Client Component 에서 사용하게 된다면, 이러한 단계를 거친다.
1. 유저에게 JavaScript 번들을 보낸다.
2. 유저가 JavaScript 번들을 다운받는다.
3. React 가 실행된다.
4. 모든 컴포넌트가 마운트된다.
5. 데이터 fetch 작업이 이루어진다.
Server Component 에서 사용하게 된다면 서버 사이드에서 데이터를 fetch 하여 더 빠르고 원활하게 전달할 수 있다.
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { pokemonOptions } from '@/app/pokemon'
import { getQueryClient } from '@/app/get-query-client'
import { PokemonInfo } from './pokemon-info'
export default function Home() {
const queryClient = getQueryClient()
void queryClient.prefetchQuery({
queryKey: ['pokemon'],
queryFn: async () => {
const response = await fetch('https://pokeapi.co/api/v2/pokemon/25')
return response.json()
},
})
return (
<main>
<h1>Pokemon Info</h1>
<HydrationBoundary state={dehydrate(queryClient)}>
<PokemonInfo />
</HydrationBoundary>
</main>
)
}
prefetching 을 통해 서버 사이드에서 fetch 작업을 처리하고, 그 결과가 캐싱되어 다른 Client Component 가 캐싱된 데이터를 받아온다.
여기서 위의 Query Client 생성 코드를 보면, staleTime: 60 * 1000, 이 설정된 것을 볼 수 있다. 이는 prefetch 된 데이터를 받아오면서 기본적으로 Tanstack Query 가 클라이언트에서 fetch 를 진행하는 행동을 막기 위한 부분이다.
prefetchQuery가 아닌fetchQuery로 데이터를 직접적으로 사용한다면, Server Component 에서fetchQuery로 받아온 데이터가 Client Component 에서fetchQuery로 받아온 데이터와 리렌더링 이후 차이가 발생할 위험이 있다.
// pages/posts.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
// This could also be getServerSideProps
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// This useQuery could just as well happen in some deeper child to
// the <PostsRoute>, data will be available immediately either way
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// This query was not prefetched on the server and will not start
// fetching until on the client, both patterns are fine to mix
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
getServerSideProps 를 통해 서버에서 prefetch 한 쿼리를 클라이언트에 보내고, 클라이언트에서 추가적으로 useQuery 를 사용하여 쿼리를 fetch 할 수도 있다.
아래와 같이 HydrationBoundary 를 전역으로 설정해 PostsRoute 와 같은 boilerplate 를 줄이고 바로 Posts 를 export default 로 처리할 수 있다.
// _app.tsx
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
// pages/posts.tsx
// Remove PostsRoute with the HydrationBoundary
// and instead export Posts directly:
export default function Posts() { ... }
prefetch 함수는 에러를 던지지 않는다. 에러를 catch 해야 한다면 fetchQuery 와 fetchInfiniteQuery 를 사용해야 한다.
만약 실패한 쿼리를 dehydrated 상태에 포함해 retry 를 줄이고 싶다면, shouldDehydrateQuery 를 사용할 수 있다.
참고로 Query Client 설정 부분에 나온 아래 설정이 이 처리이다.
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
// include pending queries in dehydration
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
})
queryClient 를 받아 dehydrate 함수에서 처리할 수도 있다.
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
// This will include all queries, including failed ones,
// but you can also implement your own logic by inspecting `query`
return true
},
})
SSR 에서는 QueryClient 를 요청마다 만들어 전달한다. Tanstack Query 는 gcTime 동안 메모리에 예약되는 독립적인 캐시를 만들기 때문에 많은 요청이 들어온다면 메모리 소모가 커질 수 있다.
서버에서 gcTime 은 기본적으로 Infinity 로, 가비지 컬렉션을 비활성화하고 요청이 끝났을 때 자동으로 메모리를 초기화한다. 만약 유한한 gcTime 을 설정했다면 미리 캐시를 정리해야한다.
gcTime 을 0으로 설정하면 hydration 오류가 발생하므로 주의해야한다. 이 오류는 HydrationBoundary 가 렌더링에 필요한 정보를 캐시에 저장하여 가비지 컬렉터가 렌더링이 완료되기 전에 데이터를 지우며 발생한다.
gcTime 을 적게 설정하고 싶다면, 2 * 1000 으로 설정하여 데이터를 참고할 수 있는 충분한 시간을 제공하는 것이 좋다.
Server Component 에서 하위 컴포넌트가 Client 관련 작업이나 컴포넌트가 아닌 단순 Server Component 라면 Tanstack Query 가 아닌 fetch 를 사용하는 것이 낫다.
공식 문서에서 아래의 내용을 포함하고 있는 글입니다.