어떤 특정 값을 먼저 받아오거나 어떤 조건이 되었을 때 쿼리 함수를 실행하려면 다음과 같이 enabled 옵션을 사용하면 된다.
const { data: userInfoData } = useQuery({
queryKey: queryKey,
queryFn: queryFn,
enabled: !!username,
});
쿼리 키에 페이지 정보를 포함해서 페이지네이션을 구현할 수 있다. placeholderData 옵션을 활용하면, 새로운 페이지를 보여줄 때 이전의 데이터를 보여 주다가 새로운 데이터가 오면 자연스럽게 전환할 수 있다.
const {data: postsData } = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page, PAGE_LIMIT),
placeholderData: keepPreviousData,
});
prefetchQuery() 함수를 사용하면 다음 페이지의 데이터를 미리 fetch하도록 구현할 수도 있다.
useEffect(() => {
if (!isPlaceholderData && postsData?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => getPosts(page + 1, PAGE_LIMIT),
});
}
}, [isPlaceholderData, postsData, queryClient, page]);
Infinite Query
const {
data: postsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => getPosts(pageParam, LIMIT),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
});
useInfiniteQuery() 훅에서는 page param 값을 활용하여 페이지를 더 불러온다.
useQuery()에서는 data에 한 페이지에 해당하는 데이터만 담고 있었지만, useInfiniteQuery()에서는 data에 모든 페이지의 데이터가 pages라는 프로퍼티로 배열에 담겨 있다.
getNextPageParam() 함수에서 다음 페이지가 있는 경우 다음 page param 값을 리턴하는데, fetchNextPage() 함수에서는 이렇게 리턴된 page param 값을 쿼리 함수로 전달해 다음 페이지의 데이터를 받아온다.
만약 getNextPageParams() 함수에서 undefined나 null 값을 리턴하면 다음 페이지가 없는 것으로 간주해 fetchNextPage() 함수를 실행해도 더 이상 데이터를 받아오지 않고, hasNextPage의 값도 false가 된다.
Optimistic updates는 서버가 제대로 동작할 것을 낙관적으로 기대하며, 서버로부터의 리스폰스를 기다리지 않고 유저에게 바로 피드백을 주는 방식이다.
useMutation()의 onMutate, onError, onSettled 옵션을 활용해 Optimistic updates를 구현할 수 있다.
const likeMutation = useMutation({
mutationFn: async ({ postId, username, userAction }) => {
if (userAction === USER_ACTION.LIKE_POST) {
await likePost(postId, username);
} else {
await unlikePost(postId, username);
}
},
onMutate: async ({ postId, username, userAction }) => {
await queryClient.cancelQueries({
queryKey: [QUERY_KEYS.LIKE_STATUS, postId],
});
await queryClient.cancelQueries({
queryKey: [QUERY_KEYS.NUM_OF_LIKES, postId],
});
const prevLikeStatus = queryClient.getQueryData([
QUERY_KEYS.LIKE_STATUS,
postId,
username,
]);
const prevLikeCount = queryClient.getQueryData([
QUERY_KEYS.LIKE_COUNT,
postId,
]);
queryClient.setQueryData(
[QUERY_KEYS.LIKE_STATUS, postId, username],
() => userAction === USER_ACTION.LIKE_POST
);
queryClient.setQueryData([QUERY_KEYS.LIKE_COUNT, postId], (prev) => {
userAction === USER_ACTION.LIKE_POST ? prev + 1 : prev - 1;
});
return { prevLikeStatus, prevLikeCount };
},
onError: (err, { postId, username }, context) => {
queryClient.setQueryData(
[QUERY_KEYS.LIKE_STATUS, postId, username],
context.prevLikeStatus
);
queryClient.setQueryData(
[QUERY_KEYS.LIKE_COUNT, postId],
context.prevLikeCount
);
},
onSettled: (data, err, { postId, username }) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.LIKE_STATUS, postId, username],
});
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.LIKE_COUNT, postId],
});
},
});
우리가 옵티미스틱 업데이트를 통해 변경하려고 하는 데이터가 refetch로 인해 덮어씌워지는 것을 막기 위해 cancelQueries()를 실행하여, 좋아요 관련 데이터를 받아 오지 않도록 쿼리를 취소해 준다.
에러가 발생했을 때는 이전의 데이터로 롤백해 줘야 하는데요. 롤백용 데이터를 따로 저장해 준다.
우리가 원하는 값으로 쿼리 데이터를 미리 변경한다.
마지막으로 롤백용 데이터를 리턴해 주면 된다.
롤백용 데이터를 세 번째 파라미터인 context로 받아 온다. context 값으로 쿼리 데이터를 변경해 준다.
에러 여부와 상관없이 백엔드 서버와 데이터를 동기화해주기 위해 좋아요 관련 데이터 쿼리를 invalidate해 준다.
리액트 쿼리는 Next.js와 같은 서버 사이드 렌더링을 제공하는 프레임워크와 함께 사용할 수도 있다.
서버에서 리액트 쿼리를 사용하면 클라이언트에서 쿼리를 실행해서 데이터를 가져올 때까지 기다리는 것이 아니라 서버에서 쿼리를 실행해 데이터를 prefetch 할 수 있다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export default function App({ Component, pageProps }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 보통 SSR에서는 staleTime을 0 이상으로 해줌으로써
// 클라이언트 사이드에서 바로 다시 데이터를 refetch 하는 것을 피한다.
staleTime: 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
}
먼저 App 컴포넌트에서 초기 설정을 해준다. Next.js에서는 기존의 리액트 프로젝트와는 다르게 App 컴포넌트 안에 새로운 QueryClient를 useState()를 사용해 state로 선언해 줘야 한다.
App 컴포넌트 바깥에 선언하게 되면 서버 렌더링시 쿼리 캐시가 다른 사용자들과 리퀘스트 간에 공유가 될 수 있기 때문에 반드시 App 컴포넌트 내부에 선언을 해주고, Next.js에서는 페이지를 이동하면 App 컴포넌트부터 새롭게 렌더링되기 때문에 쿼리 클라이언트가 매번 새롭게 생성되는 것을 막기 위하여 state로 저장해 준다.
리액트 쿼리에서는 두 가지 방법으로 prefetching을 지원한다.
export async function getServerSideProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts,
})
// ...
}
이 방법은 Next.js에서 정적 생성, 서버 사이드 렌더링을 하면서 prefetch한 데이터를 useQuery()의 initialData로 설정해 주는 방법이다. 사용 방법이 매우 간단한데요, prefetching 단계에서는 리액트 쿼리를 전혀 사용하지 않아도 된다는 장점이 있다.
다만 몇 가지 단점들도 있다.
getStaticProps(), getServerSideProps()는 pages 폴더 안에서만 동작하기 때문에 useQuery()를 사용하려는 컴포넌트까지 prefetch한 데이터를 props drilling으로 내려 줘야 한다.
또한 같은 쿼리의 useQuery()를 여러 군데서 사용한다면, 모든 useQuery()에 똑같은 initialData를 설정해 줘야 하는 문제도 있음
쿼리가 서버로부터 언제 fetch 되었는지 정확히 알 수 없기 때문에, dataUpdatedAt의 시간이나 쿼리 refetching이 필요한지 여부는 페이지가 로드된 시점으로부터 계산된다는 한계점도 있다.
추가적으로 만약 어떤 쿼리 키로 캐싱된 데이터가 이미 있다면 initialData는 해당 데이터를 절대 덮어쓰지 않는데, 따라서 이미 캐싱된 데이터가 더 오래 된 것이더라도, getServerSideProps() 함수로 받아 온 데이터는 initialData로 설정이 되기 때문에 새로운 데이터로 업데이트할 수 없다는 단점이 있다.
import { dehydrate, HydrationBoundary, QueryClient, useQuery } from '@tanstack/react-query'
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 이 쿼리는 서버에서 prefetch하지 않는 데이터.
// prefetch하는 데이터와 아닌 데이터를 자유롭게 섞어서 활용할 수 있다.
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
Hydration은 간단히 말해서 이미 렌더링된 HTML과 리액트를 연결하는 작업을 말한다. 정적인 HTML을 리액트 코드와 연결해서 동적인 상태로 바꿔 주는 걸 수분을 보충한다는 의미로 hydrate이라고 표현한다.
dehydrate은 hydrate의 반대되는 말로, 동적인 것을 다시 정적인 상태로 만드는 작업을 말이다. 위 코드에서는 prefetch한 결괏값이 담긴 queryClient를 dehydrate해서 클라이언트로 보내 주었다.
이렇게 하면 초기 설정 코드가 늘어나지만 initialData를 이용하면서 발생하는 여러 단점을 모두 해결할 수 있다.