// 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 App({ Component, pageProps }: AppProps) {
// Instead do this, which ensures each request has its own cache:
const [queryClient] = useState(()=>new QueryClient())
// or
const queryClientRef = useRef<QueryClient>()
if(!queryClientRef.current) queryClientRef.current = new QueryClient()
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
_app.tsx
에서 QueryClient설정에 2가지 방법이 있는데, 이는 컴포넌트 라이프사이클 당 QueryClient
를 오직 한 번만 생성하여 데이터가 서로 다른 사용자와 요청 간에 공유되지 않게 하기 위함이다.
위의 3가지 방법으로 한 번만 생성 하도록 할 수 있다.
React-Query의 공식 문서에서는 SSR환경에서 데이터를 받아올 때 2가지의 방법을 소개한다.
Next.js의 getStaticProps
나 getServerSideProps
함수를 통해 fetch한 데이터를 useQuery
의 initialData
옵션을 통해서 pre-fetching 후 Props를 내려 줄 수 있다.
const [queryClient] = useState(()=>new QueryClient({
defaultOptions:{
queries: {
staleTime:60000// 전반적인 query의 staleTime을 조절
}
}
}))
// or
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
staleTime을 설정 후
const ReactQueryInitialData = ({ data }: { data: AxiosResponse }) => {
const query = useQuery({
queryKey: ["react-query"],
queryFn: getInitalData,
initialData: data, // initialData는 캐시에서 유지된다.
});
//...생략
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { data } = await getInitalData();
return { props: { data } };
}
위와 같이 하면 Client단에서 다시 호출하는 일 없이, 데이터를 pre-fetching하여 사용 할 수 있다.
그러나 React-Query 공식문서상에서 나와있듯이
데이터 전달의 복잡성
initialData
의 depths가 깊으면, 해당 지점까지 도달해야 하는데, 여러 위치에서 동일한 쿼리를 호출 할 경우 앱 변경 시 오류가 발생 할 수 있다. ->useQuery
를 같은 query로 여러 위치 호출하면, 호출한 지점 모두에 initialData
넘겨줘야함데이터 신선도 문제
useQueryClient().getQueryState(["react-query"]) = {dataUpdatedAt:...}
과 같은 이 메소드를 사용해서 쿼리를 다시 가져와야 하는지 판단 해야 한다.getServerSideProps
를 사용하는 경우, 페이지를 여러 번 이동 할 때마다 새로운 데이터를 가져오지만, 초기 데이터 옵션을 사용하면 클라이언트 캐시와 데이터가 업데이트되지 않는다. 물론 쿼리에 staleTime : 30초
를 설정한 경우 초기 데이터를 동기적으로 얻을 수 있으며, 해당 데이터는 30초간 유효하기 때문에 backend에 요청할 필요가 없지만
initialDataUpdatedAt
을 사용하여 해결 할 수는 있다.
-> initialData
가 생성되었을 때 React-query에 알려 백그라운드 다시 가져오기가 트리거 된다.
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["react-query"],
queryFn: () => getInitalData(1),
initialData: data,
staleTime: 8000,
initialDataUpdatedAt: () => {
return queryClient.getQueryState(["react-query"])?.dataUpdatedAt;
},
});
console.log(queryClient.getQueryState(["react-query"])); // 8초 이후 다시 호출 된다.
hydrate
방식에는 초기 설정을 해야하는데,
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
export default function App({ Component, pageProps }: AppProps) {
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydrateState}> // HydrationBoundary로 v5에서 바뀜
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
위와 같이 HydrationBoundary
로 Component를 감싸주고, pageProps의 프로퍼티로 state
통에 넣는 로직을 짜주면 끝이다.
그리고 각 Page에서 getServerSideProps
또는 getStaticProps
의 return 값을 주면 된다.
여기서 주의해야 할 점은 Serialized
될 data가 undefined
이면 serialized가 되지 않으므로 data || null로 return 해줘야 한다.
아래는 임의의 Page에서 사용한 예시코드이다.
interface Data {
userId: 1;
id: 1;
title: "delectus aut autem";
completed: false;
}
const getInitalData = async (query: number) => {
const { data } = await axios(
`https://jsonplaceholder.typicode.com/todos/${query}`,
);
return data ?? null; // dehydrate method에서는 undefined가 허용이 안된다.
};
const ReactQueryHydration = ({ data }: { data: Data }) => {
//...생략
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["hydration"],
queryFn: () => getInitalData(1),
});
return { props: { dehydrateState: dehydrate(queryClient) } };
}
위와 같이 dehydrate
메소드안에 queryClient
를 넣어 주는 이유는 dehydrate(queryClient)
를 반환하면 queryClient는 직렬화로 변경되기 때문이다.
이때 직렬화가 가능하지 않은 값은 undefined
, Date
, Map
, Set
, BigInt
, Infinity
, NaN
, -0
, 정규식 표현등이 있다.
따라서
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["hydration"],
queryFn: () => getInitalData(1),
});
console.log(dehydrate(queryClient));
console로 확인해보면
직렬화된(JSON) 값이 보인다.
//node_modules>@tanstack>react-query>src>HydrationBoundary
HydrationBoundary = ({
state,
queryClient,
}: HydrationBoundaryProps) => {
const client = useQueryClient(queryClient) // QueryClientProvider 아래에 써줘야 하는 이유
//...생략
const queryCache = client.getQueryCache()
const queries = (state as DehydratedState).queries || []
const newQueries: DehydratedState['queries'] = []
const existingQueries: DehydratedState['queries'] = []
if (state) {
if (typeof state !== 'object') {
return // 1.
}
for (const dehydratedQuery of queries) {
const existingQuery = queryCache.get(dehydratedQuery.queryHash) // 2.
if (!existingQuery) {
newQueries.push(dehydratedQuery)// 3.
} else {
const hydrationIsNewer =
dehydratedQuery.state.dataUpdatedAt >
existingQuery.state.dataUpdatedAt // 4.
const queryAlreadyQueued = hydrationQueue?.find( // 5.
(query) => query.queryHash === dehydratedQuery.queryHash,
)
if ( // 6.
hydrationIsNewer &&
(!queryAlreadyQueued ||
dehydratedQuery.state.dataUpdatedAt >
queryAlreadyQueued.state.dataUpdatedAt)
) {
existingQueries.push(dehydratedQuery)
}
}
if (newQueries.length > 0) { // 3
// It's actually fine to call this with queries/state that already exists
// in the cache, or is older. hydrate() is idempotent for queries.
hydrate(client, { queries: newQueries }, optionsRef.current)
}
if (existingQueries.length > 0) {
setHydrationQueue((prev) =>
prev ? [...prev, ...existingQueries] : existingQueries,
)
}
}
dehydrateState
값이 없다면 return
state
로 넣어줬던 직렬화된 dehydrateState
는 Client의 Cache
에 있는지를 판별하여newQueries
라는 배열로 집어 넣고, 직렬화를 풀어주고(hydrate()
) Querylient안에 넣어준다. dehydrateState
가 있다면 데이터가 이전 데이타 보다 최신화인지를 판별하고,hydrationQueeu
안에 이미 있는지를 queryHash
값으로 판별한다.dehydrtae
된 상태값을 다시 넣어 hydrate
한다.그리고,
hydrationIsNewer
)인지 그리고 값의 상태가 이전 값보다 fresh하면 existingQueries
에 넣어준다. 즉 클라이언트에서도 서버에서 pre-fetching
한 데이터가 QueryClient안에 캐싱되게 한다. 그리고 SSR을 통해 받아온 값이 어디있는지를 보려면 아래와 같이 확인하면 된다.
console.log(useQueryClient().getQueryCache())
위의 사진을 보면 QueryCache
안의 queries
에 캐싱된 SSR데이터가 있는 것이 보인다.
왜 서버측에서 prefetch를 사용하는지, 나는 궁금했다. "fetchQuery 사용하면 돼지않나? 아니면 useQuery는?" 라는 의구심이 든다.
queryClient.fetchQuery({...}) => Promise<TData>
// not has options, otherwise has options in useQuery
이렇게 되어있기 때문이다.
queryClient.prefetch({...}) => Promise<voide>
prefetch와 fetchQuery는 정확이 같지만, 단지 반환 값의 유무를 다루지 않는다.
사실 적절히 둘 중에 하나 사용하면 된다고 생각한다.
단순히 query를 미리 하냐,
아니면 query를 통한 값에 따라 SSR
을 하고 싶냐에 차이라 생각한다 .
TkDodo의 stack-overflow에서도 prefetch관련 내용을 다루고 아래와 같이 다루고 있다.
이를 미루어 생각해본 결과, 상황에 따라 prefetch
, fetch
를 사용 하면 될것 같다 .
Next.js에서 SSR을 할 때, Hydrate
, Serialize
, queryClient.cache
개념을 찾아보고, 정리하고, 이해하고, 해석하는데 참 많은 시간을 기울인것같다.
공부를 하면서 느낀거지만, React-Query의 방대한 기능들을 다 사용할 수 있을까? 라는 생각도 들고,
prefetch
, fetch
메소드와 같이 상황에 맞추어 사용하는 것도 중요하다고 생각이든다.
무조건 무엇을 써야지가 아닌, 유동적으로 적재적소에서 코드를 '올바르게 작성' 하는 것이 중요하다고 느꼈다.