NextJS의 가장 큰 장점은 SSR이라는 점, 그리고 앱에 있는 페이지들이 pre-rendering 된다는 점이다.
그렇다면 NextJS 와 React-Query를 어떻게 같이 잘 사용할 수 있는가? 궁금해서 잘 정리된 영문 기술 블로그와 공식문서를 참고해서 정리해보았다.
React Query는 서버에서 데이터를 미리 가져와(prefetching) queryClient에 전달하는 두 가지 방법을 지원한다.
- data를 직접 prefetch 하여
initialData
에 pass 해준다.
- 단순한 case인 경우 빠르게 set up 가능
- 몇 가지 주의사항 있음
- 서버에서 query를 prefetch하고, 캐시를 dehydrate하고, client에서 rehydrate한다
- 약간 더 많은 set up 이 필요함
Next.js에서는 두 가지 형태의 pre-rendering을 지원한다.
- Static Generation(SSG)
- Server-side Rendering (SSR)
React Query는 위 두 형태의 pre-rendering을 모두 지원한다.
initialData
Next.js의 getStaticProps
혹은 getServerSideProps
을 사용하여 fetch한 data를 useQuery
의 initialData
옵션에 pass 해줄 수 있다.
React Query의 관점에서는 둘 다 동일한 방식으로 통합된다.
export async function getStaticProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts, // pass it to initialData
})
// ...
}
setup이 간단하다는 장점이 있지만, ❗️tradeoff❗️가 존재한다.
useQuery
를 호출하는 경우 initialData
를 ❗️Prop drilling❗️해줘야 함.useQuery
를 호출하는 경우 해당하는 모든 위치에 initialData
를 전달해야 한다.dataUpdatedAt
및 refetching이 필요한지 여부를 결정하는 것은 페이지가 언제 로드 되는지를 기준으로 한다.React Query는 Next.js의 서버에서 ✨여러 query를 미리 가져온 다음, ✨해당 query를 queryClient로 dehydration하는 것을 지원한다.
즉, 1) 서버가 페이지 로드 즉시 사용할 수 있는 markup을 prerender할 수 있고, 2) JS 로드 즉시 React Query는 라이브러리의 전체 기능으로 해당 query를 업그레이드하거나 hydrate 할 수 있다.
서버에 렌더링 된 이후 시간이 지나 stale 상태로 변한 query를 refetch하는 것 또한 포함된다.
서버에서 캐싱 queries과 hydration을 설정하기 위해서는
- 👉🏻App 안에, 그리고 👉
ref
인스턴스(혹은 React state) 를 사용하여 새로운QueryClient
을 만든다.
- 이렇게 하면 서로 다른 사용자와 요청 간에 데이터를 공유하지 않고, 컴포넌트 라이프사이클 당 👍🏻한 번만 Queryclient을 만들 수 있다.
- App 컴포넌트를
<QueryClientProvider>
로 감싸준 뒤 client instance를 넘겨준다.- App 컴포넌트를
<Hydrate>
로 감싸준 뒤pageProps
의dehydratedState
를 prop로 넘겨준다.
// _app.jsx
import {
Hydrate,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
이제 getStaticProps(SSG용)
또는 getServerSideProps(SSR용)
을 사용하여 페이지의 일부 데이터를 prefetch할 준비가 되었다.
React Query의 관점에서 이 둘은 동일한 방식으로 integrate된다.
getStaticProps
를 사용하는 경우를 보자.
- 각 페이지 요청에 대해 새로운
QueryClient
인스턴스를 만든다. 이렇게 하면 사용자와 요청 간에 데이터가 공유되지 않는다.- 클라이언트
prefetchQuery
메서드를 사용하여 데이터를 prefetch하고 완료될 때까지 기다린다.- dehydrate을 사용하여 query 캐시를 dehydrate하고,
dehydratedState
prop를 통해 페이지에 전달한다. 이것은 캐시가_app.js
에서 선택되는 것과 동일한 prop이다.
// pages/posts.jsx
import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query'
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery(['posts'], getPosts)
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// This useQuery could just as well happen in some deeper child to
// the "Posts"-page, 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: otherData } = useQuery({
queryKey: ['posts-2'],
queryFn: getPosts,
})
// ...
}
👍🏻 이처럼, 일부 query를 미리 가져오고 다른 query는 queryClient
에서 가져오도록 하는 것이 좋다.
즉, 특정 query에 대해 prefetchQuery
를 추가하거나 제거함으로써 ✨서버가 렌더링할 content를 제어✨할 수 있다.
Next.js의 rewrites 와 Automatic Static Opimization 혹은 getStaticProps
을 함께 사용하는 경우,
React Query에 의해 두 번째 hydration을 일으킨다.
이는 Next.js가 hydration 이후 클라이언트에서 rewrites를 파싱하고 모든 params를 collect함으로써 router.query
에서 제공될 수 있도록 해야 하기 때문이다.
첫 번째로는 Project를 만든다.
react query 공식문서에 의하면, _app.js
파일에서 hydration을 설정해줘야 한다.
//pages/_app.js
import { useState } from 'react';
import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from 'lib/react-query-config';
function MyApp({ Component, pageProps }) {
// This ensures that data is not shared
// between different users and requests
const [queryClient] = useState(() => new QueryClient(config))
return (
<QueryClientProvider client={queryClient}>
// Hydrate query cache
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
export default MyApp;
v3에서 React Query는 기본 5분 동안 쿼리 결과를 캐싱한 다음, 해당 데이터를 수동으로 garbage collecting 한다. 이 기본값은 서버 측 React Query에도 적용되었다. 이로 인해 메모리 사용량이 높아지고, 이 수동 가비지 컬렉팅이 완료되기를 기다리는 hanging 프로세스가 발생한다.
하지만 v4에서는 기본적으로 서버 측 cacheTime 이 Infinity(무한)으로 설정되어 수동 가비지 컬렝팅을 효과적으로 비활성화 한다. (NodeJS 프로세스는 request가 완료되면 모든 것을 지운다.)
이제 데이터를 prefetch하고 getServerSideProps
메서드에서 queryClient
를 dehydrate해야 한다.
//pages/posts/[id].js
import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';
export const getServerSideProps = async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient()
// prefetch data on the server
await queryClient.fetchQuery(['post', id], () => getPost(id))
return {
props: {
// dehydrate query cache
dehydratedState: dehydrate(queryClient),
},
}
}
참고: prefetchQuery
는 에러를 throw하지 않거나 어떠한 데이터도 return하지 않으므로 fetchQuery
를 사용했다. 이는 404 상태 코드 처리에서 더 자세히 다뤄보도록 하자.
지금부터는 data를 props 로 전달하지 않고도 prefetched 된 데이터를 페이지에서 쉽게 사용할 수 있다.
명확히 하기 위해 getPost
메소드와 usePost
hook 의 구현을 살펴보자.
//api/posts.js
import axios from 'lib/axios';
export const getPost = async id => {
const { data } = await axios.get('/posts/' + id);
return data;
}
//hooks/api/posts.js
import { useQuery } from '@tanstack/react-query';
import * as api from 'api/posts';
export const usePost = (id) => {
return useQuery(['post', id], () => api.getPost(id));
}
이제 우리는 usePost
hook을 사용하여 post 데이터를 가져올 수 있다.
//pages/posts/[id].js
import { useRouter } from 'next/router';
import { usePost } from 'hooks/api/posts'
import Loader from 'components/Loader';
import Post from 'components/Post';
import Pagination from 'components/Pagination';
const PostPage = () => {
const { query: { id } } = useRouter();
const { data, isLoading } = usePost(id);
if (isLoading) return <Loader />
return (
<>
<Post id={data.id} title={data.title} body={data.body} />
<Pagination id={id} />
</>
)
}
// getServerSideProps implementation ...
// We talked about it in section 2
우리는 client에서 data fetching과 caching 메커니즘을 관리하기를 원한다.
매번 getServerSideProps
호출을 방지하기 위해 post 페이지 간 navigating 할 때 Link 컴포넌트에서 shallow=true
prop을 사용해야 한다.
이로써 getServerSideProps
메서드는
App 내의 클라이언 사이드 navigation이 일어날 때가 아니라 사용자가 post의 URL을 직접 누를 때만 호출되도록 한다.
page 간 navigate(이동)를 하기 위한 Pagination 컴포넌트가 있으므로 여기에 shallow=true
를 사용한다.
//components/Pagination.jsx
import Link from 'next/link';
function PaginationItem({ index }) {
return (
<Link className={itemClassName} href={'/posts/' + index} **shallow={true}**>
{index}
</Link>
)
}
export default PaginationItem;
NextJS v12.2 의 shallow routing은 현재 페이지의 URL 변경에 대해서만 작동한다. (참고)
즉, shallow=true
로 /posts/10
에서 /posts/15
로 이동하면 getServerSideProps
가 호출되지 않지만
/home
에서 /posts/15
로 이동하면 shallow routing을 사용하더라도 getServerSideProps
가 호출되고, 사용 가능한 캐시 데이터가 있는데도 불필요한 새로운 데이터를 fetch한다.
getServerSideProps
에 대한 요청이 client 측의 navigation 요청인지 여부를 확인하는 방법은 아래와 같다.
(props로 빈 object를 return하고 서버에서 데이터를 fetching 하는 것을 막는다)
서로 다른 페이지를 navigating 할 때
getServerSideProps
호출을 막을 수는 없지만, ✨getServerSideProps
에서 불필요한 데이터를 가져오는 것은 막을 수 있다.✨
//HOC/with-CSR.js
export const withCSR = (next) => async (ctx) => {
// check is it a client side navigation
const isCSR = ctx.req.url?.startsWith('/_next');
if (isCSR) {
return {
props: {},
};
}
return next?.(ctx)
}
이제 getServerSideProps
를 위의 HOC로 감싸준다.
//pages/posts/[id].js
import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import { withCSR } from 'HOC/with-CSR'
export const getServerSideProps = withCSR(async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient()
await queryClient.fetchQuery(['post', id], () => getPost(id))
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
})
다른 페이지에서 post 페이지로 이동하면 getServerSideProps
는 데이터를 fetch하지 않고 props에 빈 object만 반환하게 된다.
NextJS는 만약 post가 available 하지 않을 경우 error 페이지를 렌더링하지만 error 상태 코드에 따라 respond하지는 않는다.
이는 404 에러가 보이더라도 page 가 200 코드로 응답하고 있음을 의미한다.
이를 해결하기 위해서는 아래처럼 custom error 컴포넌트를 return 해줄 수 있다.
const Page = ({ isError }) => {
**//show custom error component if there is an error
if (isError) return <Error />
return <PostPage />**
}
export const getServerSideProps = withCSR(async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient();
let isError = false;
try {
await queryClient.fetchQuery(['post', id], () => getPost(id));
} catch (error) {
isError = true
ctx.res.statusCode = error.response.status;
}
return {
props: {
//also passing down isError state to show a custom error component.
isError,
dehydratedState: dehydrate(queryClient),
},
}
})
export default Page;
참고