React Query를 Server Side에서 데이터를 미리 가져와서 queryClient에 전달하는 방법은 두 가지가 있다.
1. InitialData 사용
getStaticProps 또는 getServerSideProps에서 가져온 데이터를 useQuery의 initialData option에 전달하는 것이다.
export async function getStaticProps() {
const posts = await getPosts();
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery(['posts'], getPosts, { initialData: props.posts });
// ...
}
2. Hydration 사용
Server side에서 prefetch 후 해당 쿼리를 queryClient로 dehydrate하는 것이다. 이 방법을 더 권장한다고 한다.
Hydrate란?
한글로 '수화시키다, 수분을 유지시키다' 라는 뜻이다.
SSR(Server Side Rendering)은 pre-rendering 된, 즉 Server Side에서 렌더링 된 정적 페이지(HTML)와 번들링 된 JS 파일을 클라이언트로 보낸다. 그 다음 클라이언트에서 이 둘을 매칭하는 과정을 Hydrate라고 한다.
처음에 받은 정적 페이지는 자바스크립트가 하나도 없는 상태이기 때문에 뼈대만 있다고 생각하면 된다.
건조한 HTML에 이벤트 핸들러 등의 수분을 공급하여 동적인 페이지를 만들어가는 과정을 말한다. HTML DOM 요소 위에서 한번 더 렌더링이 되어 정상적으로 기능이 동작하게 된다.
dehydrate는 '수화시키다'의 반대인 '탈수시키다, 수분을 빼다'라는 뜻이다.
React Query 공식문서에서는 이렇게 설명한다.
dehydrate는 나중에 hydrate로 공급할 수 있는 cache에 대한 고정된 표현을 생성하며, hydrate는 이전에 dehydrate 된 state를 cache에 추가한다.
// _app.tsx
import {
Hydrate,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { useState } from 'react';
export default function MyApp({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
QueryClient의 인스턴스를 만들어 state로 저장하면 컴포넌트가 처음 마운트될 때만 queryClient가 초기화된다. 이후 페이지를 전환하거나 다시 렌더링해도 초기화가 되지 않는다.
QueryClientProvider에 queryClient를 전달하고 Hydrate의 state 속성에 pageProps.dehydratedState를 전달하면 prefetch 할 준비가 되었다.
queryClinet 인스턴스를 새로 생성해서 prefetch 후 dehydratedState에 넘기면 기존에 있던 queryClient에 query가 추가된다.
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(props) {
const { data } = useQuery(['posts'], getPosts);
// ...
}
infiniteQuery prefetch
infiniteQuery를 prefetch할 때도 똑같이 작성하면 된다.
아래 코드는 플레이리스트의 트랙들을 SSR로 가져오는 코드이다.
// playlist/[id].tsx
interface PlaylistPageProps {
id: string;
}
const PlaylistPage = ({ id }: PlaylistPageProps) => {
const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = useGetPlaylistTracks(id);
// ...
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const id = context.query.id?.toString() ?? '';
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery(['playlistTracks', id], ({ pageParam = 0 }) =>
getPlaylistTracks({ pageParam, id })
);
return {
props: { dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))), id },
};
};
export default PlaylistPage;
이렇게 prefetch를 하고 props로 dehydratedState를 넘기게 되면 __NEXT_DATA__에서 받아온 값을 확인할 수 있다.
serialize 에러
Error: Error serializing `.dehydratedState.queries[0].state.data.pageParams[0]` returned from `getServerSideProps` in "/playlist/[id]". Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value.
이 에러는 dehydratedState에 undefined 값이 있어서 생기는 문제다. null로 바꾸거나 없애야 하는데 방법은 간단하다.
dehydratedState: JSON.parse(JSON.stringify(dehydrate(queryClient))
JSON.stringify()를 사용하면 객체 내부의 값 중 undefined, 함수 등의 값들은 삭제된다. 그 다음 JSON.parse()를 사용하여 다시 객체로 변환하면 위 에러를 해결할 수 있다.
고민 1 : query 커스텀 훅에서 options를 따로 관리하고 싶음
// api/browse.ts
export const getCategory = (id: string) => {
return api({
method: 'get',
url: `https://api.spotify.com/v1/browse/categories/${id}`,
});
};
// hooks/queries/browse.ts
export const useGetCategory = (id: string) => useQuery(['category', id], () => getCategory(id));
useQuery의 세번째 인자로 options가 들어가는데 훅을 사용하는 곳에서 관리를 하고 싶었다.
해결 방법
options의 타입을 UseQueryOptions로 정할 수 있다.
useQuery의 options 타입을 보면 'queryKey'와 'queryFn'을 제외한 UseQueryOptions라는 것을 알 수 있다.
TQueryFnData에 예상되는 리턴 값을 넣어 해결할 수 있다.
// types.d.ts
export interface UseQueryOptions<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey> extends UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey> {
}
// useQuery.d.ts
export declare function useQuery<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(queryKey: TQueryKey, queryFn: QueryFunction<TQueryFnData, TQueryKey>, options?: Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, 'queryKey' | 'queryFn'>): UseQueryResult<TData, TError>;
getCategory의 리턴 값이 Promise<AxiosResponse<SpotifyApi.SingleCategoryResponse, any>>
이기 때문에 이런 식으로 작성해 보았다.
// api/browse.ts
export const getCategory = (id: string) => {
return api<SpotifyApi.SingleCategoryResponse>({
method: 'get',
url: `https://api.spotify.com/v1/browse/categories/${id}`,
});
};
// hooks/queries/browse.ts
export const useGetCategory = (
id: string,
options?: UseQueryOptions<AxiosResponse<SpotifyApi.SingleCategoryResponse>, AxiosError>
) =>
useQuery<AxiosResponse<SpotifyApi.SingleCategoryResponse>, AxiosError>(
['category', id],
() => getCategory(id),
options
);
나는 여기서 getCategory의 리턴 값을 Promise<SpotifyApi.SingleCategoryResponse>
로 바꾸어 더욱 깔끔하게 구현할 수 있었다.
// api/browse.ts
export const getCategory = async (id: string) => {
const { data } = await api<SpotifyApi.SingleCategoryResponse>({
method: 'get',
url: `https://api.spotify.com/v1/browse/categories/${id}`,
});
return data;
};
// hooks/queries/browse.ts
export const useGetCategory = (
id: string,
options?: UseQueryOptions<SpotifyApi.SingleCategoryResponse, AxiosError>
) =>
useQuery<SpotifyApi.SingleCategoryResponse, AxiosError>(
['category', id],
() => getCategory(id),
options
);
이제 훅을 쓸 때 options를 따로 관리할 수 있게 되었다.
고민 1 - 1 : 그럼 infiniteQuery에서는 어떻게 options을 분리하지?
infiniteQuery는 options에 useInfiniteQueryOptions 타입을 추가하면 되고, queryFn 리턴 값에 pageParam도 추가가 되었으므로 그에 맞는 타입을 만들어 구현했다.
// api/browse.ts
export const getCategories = async ({ pageParam = 0 }) => {
const { data } = await api<SpotifyApi.MultipleCategoriesResponse>({
method: 'get',
url: 'https://api.spotify.com/v1/browse/categories',
params: {
offset: pageParam,
},
});
return { data, pageParam };
};
// hooks/queries/browse.ts
export type InfiniteCategoriesResponse = {
data: SpotifyApi.MultipleCategoriesResponse;
pageParam: number;
};
export const useGetCategories = (
options?: UseInfiniteQueryOptions<InfiniteCategoriesResponse, AxiosError>
) =>
useInfiniteQuery<InfiniteCategoriesResponse, AxiosError>(
['categories'],
({ pageParam = 0 }) => getCategories({ pageParam }),
{
getNextPageParam: (lastPage) =>
lastPage.data.categories.next ? lastPage.pageParam + 20 : undefined,
...options,
}
);
'React' 카테고리의 다른 글
[React] 우당탕탕 라이브러리 배포해보기 (2) | 2023.04.24 |
---|---|
Spotify API 사용기 (3) - Spotify Web Playback SDK를 사용하여 음악 재생 기능 만들기 (0) | 2023.04.02 |
Spotify API 사용기 (1) - 앱 생성 및 로그인 기능 구현 (with Next.js) (0) | 2023.03.24 |
[React] 상태 관리 라이브러리의 이해 - Redux 동작 원리 (1) | 2023.02.02 |
[React] 상태 관리(feat. React-Query) (0) | 2022.09.03 |