Next.js 13 의 클라이언트 컴포넌트에서 react-query 를 사용하는 경우
서버 컴포넌트에서 데이터를 미리 가져온 뒤 클라이언트 컴포넌트에 전달하는 것에 대한 게시물 입니다.
코드 예시의 라이브러리 버전은 아래와 같습니다.
"@tanstack/react-query": "^4.24.4"
"@tanstack/react-query-devtools": "^4.24.4",
"next": "13.1.3",
"react": "18.2.0",
App 디렉토리의 서버컴포넌트에서 데이터를 미리 가져온 뒤 클라이언트 컴포넌트에 전달하는 방법이 2가지가 있습니다.
각각 차례대로 살펴보겠습니다.
- props drilling 방식으로 pre-fetch
- hydrate 방식으로 pre-fetch
react-query Provider 설정
가장 먼저 클라이언트 컴포넌트로 QueryClientProvider Wrapper 컴포넌트를 만들어 줍니다.
최상위 컴포넌트의 body 테그 안에 들어가는 children 을 이 Wrapper 컴포넌트로 감싸주어야 합니다.
// app/ReactQueryProvider.tsx
"use client";
import {
QueryClient,
QueryClientProvider,
Hydrate,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { PropsWithChildren, useState } from "react";
export default function ReactQueryProvider({ children }: PropsWithChildren) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx
import "../styles/globals.css";
import ReactQueryProvider from "./ReactQueryProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head />
<body className="overflow-y-scroll">
<ReactQueryProvider>{children}</ReactQueryProvider>
</body>
</html>
);
}
방법 1: props drilling 방식으로 pre-fetch
단순한게 장점이고 props drilling 이 생기는게 단점입니다.
// app/page.jsx
export default async function Home() {
const initialData = await getPosts()
return <Posts posts={initialData} />
}
서버 컴포넌트에서 클라이언트 컴포넌트로 props 를 전달해줍니다.
그리고 useQuery option인 initialData 에 props로 받아온 데이터를 전달해줍니다.
무척 간단합니다. 정말 간단히 만들거면 이 방법이 좋아 보이긴 합니다.
// app/posts.jsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function Posts(props) {
const {data} = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts, // props 로 전달받은 값을 넣어줍니다.
})
// ...
}
방법 2: hydrate 방식으로 pre-fetch
별도 파일을 만들어 줘야하는게 (귀찮은게) 단점입니다.
대신에 props drilling 없이 미리 가져온 쿼리를 컴포넌트 트리 아래의 모든 컴포넌트에서 사용할 수 있는게 장점입니다.
QueryClient 의 request-scoped 싱글톤 인스턴스를 만들어줍니다.
쉽게 말해서 단 1개의 QueryClient 를 돌려쓰겠다는 말입니다.
// getQueryClient.tsx
import { QueryClient } from "@tanstack/query-core";
import { cache } from "react";
// `QueryClient` 의 요청 범위 싱글톤 인스턴스를 만듭니다 .
// 이렇게 하면 서로 다른 사용자와 요청 간에 데이터가 공유되지 않고 여전히 요청당 한 번만 QueryClient를 생성합니다.
const getQueryClient = cache(() => new QueryClient());
export default getQueryClient;
react-query 의 Hydrate 요소를 클라이언트 컴포넌트로 래핑해서 클라이언트 컴포넌트에서 사용할 수 있도록 만들어 줍니다.
추후에 'use client' 지시문이 react-query 에 추가된다면 이 파일은 필요 없어질겁니다.
// hydrateOnClient.tsx
"use client";
import { Hydrate as HydrateOnClient } from "@tanstack/react-query";
export default HydrateOnClient;
이번 예시에서는 /app 디렉토리 아래에 posts 디렉토리를 만들겁니다.
posts 클라이언트 컴포넌트에 미리 가져온 데이터를 전달 하기 위한 hydratedPosts 서버 컴포넌트를 작성해줍니다.
미리 가져온 쿼리를 사용하는 클라이언트 컴포넌트보다 컴포넌트 트리에서 상위에 있는 서버 컴포넌트에서 데이터를 가져옵니다.
미리 가져온 쿼리는 컴포넌트 트리 아래의 모든 컴포넌트에서 사용할 수 있습니다.
아래와 같은 과정으로 동작합니다.
- `QueryClient` 싱글톤 인스턴스 검색
- 클라이언트의 prefetchQuery 메서드를 사용하여 데이터를 미리 가져오고 완료될 때까지 기다립니다.
- 'dehydrate' 를 사용 하여 쿼리 캐시에서 pre-fetch 된 쿼리의 hydrate State를 얻습니다.
- `<Hydrate> 클라이언트 컴포넌트 내부에 pre-fetch 된 쿼리가 필요한 컴포넌트 트리를 래핑하고 dehydrate 상태를 제공합니다.
import { API } from "@/api/api";
import { QUERY_KEYS } from "@/api/queries";
import { dehydrate } from "@tanstack/query-core";
import getQueryClient from "../../api/hooks/getQueryClient";
import HydrateOnClient from "../../api/hooks/hydrateOnClient";
import Posts from "./posts";
export default async function HydratedPosts() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery([QUERY_KEYS.POST.조회], API.POST.조회);
const dehydratedState = dehydrate(queryClient);
return (
<HydrateOnClient state={dehydratedState}>
<Posts />
</HydrateOnClient>
);
}
가져온 데이터를 보여주기 위한 UI 에 해당하는 Posts 클라이언트 컴포넌트를 만들어줍니다.
useQuery 의 초기 값으로 dehydratedState 상태를 가져오게 됩니다.
서버 렌더링 중에 <Hydrate> 클라이언트 컴포넌트 내에 중첩된 useQuery 에 대한 호출은 상태 속성에 제공된 미리 가져온 데이터에 액세스할 수 있습니다.
여기서 useGetPosts 는 useQuery 를 한번더 래핑해준 custom hook 입니다.
"use client";
import { useGetPosts } from "@/api/hooks/useGetPosts";
import { SkeletonCard } from "@/ui/SkeletonCard";
export default function Posts() {
const { data, isFetching } = useGetPosts({ queries: [], enabled: true });
return (
<section className="flex flex-col gap-5">
{data?.map((item) => {
return (
<article key={item.id} className="flex flex-col border-2 ">
<span className="font-bold">{`id: ${item.id}`}</span>
<span>{`userId: ${item.userId}`}</span>
<span>{`title: ${item.title}`}</span>
<span>{`body: ${item.body}`}</span>
</article>
);
})}
</section>
);
}
/posts 라우트의 index.tsx 역할을 하 page 서버 컴포넌트에 HydratePosts 서버 컴포넌트를 추가합니다.
참고로 TypeScript는 현재 비동기 서버 컴포넌트를 사용할 때 유형 오류에 대해 불평합니다. 그래서 임시 해결방법으로
{/* @ts-expect-error Server Component */} 를 추가해줘야 합니다.
- 자세한 내용은 이 링크 참고
app/posts/page.tsx
import HydratedPosts from "./hydratedPosts";
export default function Page() {
return (
<main className="flex w-full">
{/* @ts-expect-error Server Component */}
<HydratedPosts />
</main>
);
}
이렇게 하고 네트워크 탭을 확인해보았을때
실제로 클라이언트 상에서는 아무런 API 요청이 없음에도 데이터를 가져온 화면을 보여주는 것을 볼 수 있습니다 !
이런 방식으로 서버에서 왠만한 데이터를 다 불러오고 클라이언트에서는 최소한의 API 요청을 하게된다면
사이트가 엄청나게 개선이 될 것 같습니다.
추가로 graphql 기술을 적용하여 데이터에서 꼭 필요한 필드만 뽑아 온다면 HTML 파일의 용량이 줄어들어서 더 큰 개선이 기대됩니다.
꿀팁 하나더 추가합니다.
useInfiniteQuery 에 prefetchQuery 를 사용하려면 prefetchQuery의 응답값을 변환해서 넣어줘야 합니다 !
await queryClient.prefetchQuery([QUERY_KEYS.PHOTO.조회], () =>
API.PHOTO.조회({ page: 0 }).then((data) => {
return {
pages: [data],
};
})
);
'프론트엔드 개발 > Next.js' 카테고리의 다른 글
[프로젝트 기록] Next.js 의 SSG 는 너무 느려서 못써먹겠다. (4) | 2024.09.30 |
---|---|
Next.js + React Native 크로스 플랫폼 개발을 위한 Solito, NativeWind 소개 (0) | 2023.10.03 |
Next.js 에서 소셜 로그인 구현하기 (next-auth.js 사용) (1) | 2023.02.18 |
웹 프레임워크 Next.js 는 무엇인가요? (프론트 면접 질문) (0) | 2023.02.05 |
Next 13 버전에서 App 디렉토리 사용방법 및 소개 (3) | 2023.01.30 |