본문 바로가기

카테고리 없음

[Next.js] React query prefetching with hydrate

1. 리액트 쿼리 Hydration (v5)

*참고: https://tanstack.com/query/latest/docs/framework/react/reference/hydration

  • 'dehydrate'

- 서버 컴포넌트에서 fetch한 데이터를 클라이언트 컴포넌트에 전달하려면 serialization을 거쳐야 한다.

- serialization이란 데이터를 저장 혹은 전송 가능한 포맷으로 변환하는 과정을 말한다.

- 리액트 쿼리의 dehydrate는 이 serialization과 유사한 개념으로, 리액트 쿼리가 server-side에서 생성된 쿼리를 client-side 전달하기 위해 고정된(frozen) 형태로 만드는 과정을 말한다.

- dehydrate된 쿼리는 local storage나 다른 영구적인 위치에 저장하는 데에도 유용하다.

- 기본적으로 리액트 쿼리는 성공한 쿼리만 dehydrate하기 때문에 Error나 undefined 같은 JSON으로 직렬화 할 수 없는 값들은 포함하지 않는다. 이들은 직접 직렬화해야 한다.

  - Date, Map, Set, BigInt, Infinity, NaN, -0, 정규 표현식 등도 직렬화를 지원하지 않음. (https://tanstack.com/query/latest/docs/framework/react/guides/ssr#serialization)

// server-side
import { QueryClient, dehydrate } from '@tanstack/react-query'

const queryClient = new QueryClient();
const dehydratedState = dehydrate(queryClient);

// queryClient를 dehydrate 한다는 점에 주의할 것
// dehydratedState은 직렬화된 형식이 아님에 주의할 것

 

  • 'hydrate'

- dehydrate의 반대 과정으로, 리액트 쿼리가 dehydrate된 state를 client-side에서 캐싱한다.

// client-side
import { QueryClient, hydrate } from '@tanstack/react-query'

const queryClient = new QueryClient();
hydrate(queryClient, dehydratedState);

// client-side에서 생성한 새 queryClient에 server-side에서 dehydrate한 queryClient를 추가한다.
// 만약 dehydrate된 쿼리 중에 이미 클라이언트 queryCache에 존재하는 쿼리가 있다면 해당 쿼리의 hydration은 
// 덮어쓰지 않고 삭제된다.

 

  • 'HydrationBoundary'

- useQueryClient()를 호출하여 가장 가까운 queryClient context의 queryClient에 dehydratedState를 추가한다.

- 'hydrate'와 다르게 client에 이미 data가 존재하면, update timestamp를 기반으로 새 쿼리를 병합한다. (https://tanstack.com/query/latest/docs/framework/react/reference/hydration#hydrationboundary)

import { QueryClientProvider, HydrationBoundary } from '@tanstack/react-query'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydratedState}>
        // ...
      </HydrationBoundary>
    </QueryClientProvider>
  )
}

 

 

2. SSR에서 prefetching 데이터를 리액트 쿼리에 캐싱하기

- useQuery의 initialData 속성을 이용하여 서버에서 prefetching한 데이터를 쿼리의 초기 캐시 값으로 사용할 수 있다.

- 하지만 이 방법은 몇가지 단점이 존재한다.

  - 동일한 쿼리를 여러 하위 컴포넌트에서 실행 하는 경우에 모든 쿼리에 initialData를 전달하거나, 아니면 initialData를 적용하는 위치를 신중히 고려해야함.

  - 서버에서 쿼리가 호출되는 시점을 알 수 없어 refetching 여부는 페이지가 로드된 시점에 따라 결정됨.

  - 쿼리 캐시에 이미 데이터가 존재하는 경우에는 initialData로 기존 데이터를 덮어쓰지 않음.

 

- 'dehydrate/hydrate' API를 사용하면 이러한 문제들이 발생하지 않는다.

- 서버에서 prefetching한 데이터를 직렬화하여 클라이언트에 보내는 것이 아니라, 서버에서 data fetching에 사용한 queryClient 자체를 직렬화하여 클라이언트에서 재사용한다.

// @/app/providers/query-client-provider.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
  // This ensures each request has its own cache:
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // With SSR, we usually want to set some default staleTime
            // above 0 to avoid refetching immediately on the client
            staleTime: 60 * 1000,
          },
        },
      })
  )
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

or

// @/app/providers/query-client-provider.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (typeof window === 'undefined') {
    // 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
  }
}

export const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
  // NOTE: Avoid useState when initializing the query client if you don't
  // have a suspense boundary between this and the code that may
  // suspend because React will throw away the client on the initial
  // render if it suspends and there is no boundary
  const queryClient = getQueryClient()
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

*Suspense query: https://tanstack.com/query/latest/docs/framework/react/guides/ssr#a-quick-note-on-suspense

 

// @/app/layout.tsx
import { ReactQueryClientProvider } from '@/app/providers/query-client-provider.tsx'

// NEVER DO THIS:
// const queryClient = new QueryClient()
//
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ReactQueryClientProvider>
          {children}
        </ReactQueryClientProvider>
      </body>
    </html>
  )
}

 

// @/app/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

export default async function Page({ params }: { params: { id: number } }) {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({ queryKey, queryFn })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <SomeComponents... />
    </HydrationBoundary>
  )
}

 

import { useQuery } from '@tanstack/react-query'

function SomeComponent() {
  const { data } = useQuery({ queryKe, queryFn })
  // This useQuery could just as well happen in some deeper child to
  // the <PostsRoute>, data will be available immediately either way
  //
  // Note that we are using useQuery here instead of useSuspenseQuery.
  // Because this data has already been prefetched, there is no need to
  // ever suspend in the component itself. If we forget or remove the
  // prefetch, this will instead fetch the data on the client, while
  // using useSuspenseQuery would have had worse side effects.

  // ...
}

 

 

참고: https://tanstack.com/query/latest/docs/framework/react/guides/ssr

*Nesting Server components: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#nesting-server-components

*읽어보기: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching