React Query의 여러가지 도구들과 사용 전략에 대해 간략히 정리한 글입니다 :)
useQuery
hook을 이용해서 API를 요청하고, API 응답 데이터를 상태로 관리한다. import { useQuery } from "@tanstack/react-query";
export function Staff () {
const { data, isLoading } = useQuery({
queryKey: [staff, filter],
queryFn: getStaff,
select: (staff) => filterByTreatment(staff, filter),
});
...
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
...
export function App() {
return (
...
<QueryClientProvider client={queryClient}>
...
<ReactQueryDevtools />
</QueryClientProvider>
...
);
}
useQuery
, useMutation
, prefetch
등 query hook을 실행하면 기능 별로 코드가 분리되지 않아 코드 가독성이 떨어진다. import { useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/react-query/constants";
export function useTreatments(): Treatment[] {
const fallback: Treatment[] = [];
const { data = fallback } = useQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
});
return data;
}
export function usePrefetchTreatments(): void {
const queryClient = useQueryClient();
queryClient.prefetchQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
});
}
QueryCache
의 onError
속성으로 설정export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
errorHandler(error.message);
},
}),
});
useIsFetcing
, useIsMutating
hook 사용import { Spinner } from "../내컴포넌트";
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
export function Loading() {
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const display = isFetching && isMutating ? "inherit" : "none";
return (
<Spinner $display={display}/>
);
}
currentPage
, maxPage
를 useState로 관리queryKey
에 currentPage
값을 추가 -> currentPage가 변경되면 query
가 재호출됨import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchPosts, deletePost, updatePost } from "./api";
import { PostDetail } from "./PostDetail";
const maxPostPage = 서버에서 주는 maxPage
export function Posts() {
const [currentPage, setCurrentPage] = useState(1);
const [selectedPost, setSelectedPost] = useState(null);
const { data, isLoading, isError } = useQuery({
queryKey: ["blog", "post", "list", currentPage],
queryFn: () => fetchPosts(currentPage),
staleTime: 2000, //2 seconds
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error!</div>;
return (
<>
<ul>
{data.map((post) => (
<li
key={post.id}
className="post-title"
onClick={() => setSelectedPost(post)}
>
{post.title}
</li>
))}
</ul>
<div className="pages">
<button
disabled={currentPage <= 1}
onClick={() => {
setCurrentPage((prev) => prev - 1);
}}
>
Previous page
</button>
<span>Page {currentPage}</span>
<button
disabled={currentPage >= maxPostPage}
onClick={() => {
setCurrentPage((prev) => prev + 1);
}}
>
Next page
</button>
</div>
<hr />
{selectedPost && <PostDetail post={selectedPost} />}
</>
);
}
queryClient
의 객체임. 얘의 queryKey도 useQuery와 같아야 하니까 useQuery Hook이 있는 파일에 같이 hook으로 분리해주면 코드가 더 깔끔해질 것임.// /hooks/useTreatments.ts
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Treatment } from "@shared/types";
import { axiosInstance } from "@/axiosInstance";
import { queryKeys } from "@/react-query/constants";
// for when we need a query function for useQuery
async function getTreatments(): Promise<Treatment[]> {
const { data } = await axiosInstance.get("/treatments");
return data;
}
export function useTreatments(): Treatment[] {
const fallback: Treatment[] = [];
const { data = fallback } = useQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
});
return data;
}
export function usePrefetchTreatments(): void {
const queryClient = useQueryClient();
queryClient.prefetchQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
});
}
//pages/Home.tsx
import { usePrefetchTreatments } from "../treatments/hooks/useTreatments";
export function Home() {
usePrefetchTreatments();
return (
<Stack textAlign="center" justify="center" height="84vh">
...
</Stack>
);
}
npm i @lukemorales/query-key-factory
query-key-factory
라이브러리의 createQueryKeyStore()
를 이용해서 만들어준다. import { createQueryKeyStore } from '@lukemorales/query-key-factory'
export const queryKeys = createQueryKeyStore({
todos: {
detail: (todoId: string) => [todoId],
list: (filters: TodoFilters) => ({
queryKey: [{ filters }],
queryFn: (ctx) => api.getTodos({ filters, page: ctx.pageParam }),
}),
},
})
const {data} = useQuery({
queryKey: [...queryKeys.detail, ...]
})
select
옵션을 사용하면 좋다. selectFn
은 useCallback
hook을 사용하는 것이 바람직하다. 왜냐하면 useCallback
을 사용하지 않으면 useQuery
가 리패치되지 않았는데도 컴포넌트가 마운트될 때 selectFn은 재실행되기 때문이다. queryClient.prefetchQuery
를 실행할 때는 select 옵션을 설정하지 않아도 되는데, 이는 select
옵션의 결과는 사실 react query의 캐시 데이터로 관리되는 것은 아니기 때문이다. 그저 selectFn
은 데이터가 한 번 리패치 될 때 실행되는 함수임import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { getAvailableAppointments } from "../utils";
export function useAppointments() {
const [showAll, setShowAll] = useState(false);
const fallback: AppointmentDateMap = {};
const selectFn = useCallback(
(data: AppointmentDateMap, showAll: boolean) => {
if (showAll) return data;
return getAvailableAppointments(data, userId);
},
[userId]
);
const { data: appointments = fallback } = useQuery({
queryKey: [
queryKeys.appointments,
{ year: monthYear.year, month: monthYear.month },
],
queryFn: () => getAppointments(monthYear.year, monthYear.month),
select: (data) => selectFn(data, showAll),
});
}
refetchInterval
이 경과한 경우queryClient
에 전역으로 설정할 수도 있고, useQuery
에 특정하여 설정할 수도 있음)refetchOnMount
(boolean) : mount될 때 리패치 실행 여부refetchOnWindowFocus
(boolean) : window가 focus되었을 때 리패치 실행 여부refetchOnReconnect
(boolean) : 네트워크 재연결되었을 때 리패치 실행 여부refetchInterval
(밀리초 단위의 시간) : 자동으로 리패치 해줄 시간useQuery
에서 return하는 객체에도 refetch
함수가 있다. -> 임의로 refetch
를 실행해줄 수 있음refetchOnMount
, refetchOnWindowFocus
, refetchOnReconnect
옵션을 끈다.refetch
를 막는 것이 좋다.export function useTreatments(): Treatment[] {
const fallback: Treatment[] = [];
const { data = fallback } = useQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
staleTime: 1000 * 60 * 10, //10분
gcTime: 1000 * 60 * 15, //15분
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
return data;
}
export function usePrefetchTreatments(): void {
const queryClient = useQueryClient();
queryClient.prefetchQuery({
queryKey: [queryKeys.treatments],
queryFn: getTreatments,
staleTime: 1000 * 60 * 10, //10분
gcTime: 1000 * 60 * 15, //15분
// prefetch는 일회성이므로 refetch 관련 옵션은 설정하지 않는다.
});
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 10, //10 minutes
gcTime: 1000 * 60 * 15, //15 minutes
},
},
...
});
const { data: appointments = fallback } = useQuery({
queryKey: [ ... ],
queryFn: () => getAppointments(...),
select: (data) => selectFn(...),
refetchInterval: 1000 * 60, //1분마다 쿼리 재호출
...,
});
staleTime: infinity
: 쿼리가 한 번 실행된 후 query cache가 없어지지 않는 한 영원히 재호출 되지 않음export function useUser() {
const { userId, userToken } = useLoginData();
const { data: user } = useQuery({
queryKey: generateUserKey(userId, userToken),
queryFn: () => getUser(userId, userToken),
staleTime: Infinity, // user 데이터를 한 번 조회한 후 다시 refetch 하지 않음
});
return { user };
}
enabled
: 특정 상황에서 query가 실행되지 않게끔 설정할 때 사용하는 옵션userId
, userToken
값이 유효할 때만 useQuery
가 fetch되도록 enabled
option을 설정한 예제임. export function useUser() {
const { userId, userToken } = useLoginData();
const { data: user } = useQuery({
enabled: !!userId, //userId가 유효하지 않으면 이 쿼리가 실행되지 않음
queryKey: generateUserKey(userId, userToken),
queryFn: () => getUser(userId, userToken),
staleTime: Infinity, // user 데이터를 한 번 조회한 후 다시 refetch 하지 않음
});
return { user };
}
queryClient.setQueryData
queryClient.removeQueries
import { useQueryClient } from "@tanstack/react-query";
export function useUser() {
const queryClient = useQueryClient();
function updateUser(newUser: User): void {
queryClient.setQueryData(
generateUserKey(newUser.id, newUser.token), //queryKey
newUser // 설정할 cache data
);
}
}
mutate
를 return하여 mutationFn
을 실행시킬 수 있다. useQuery
와 비슷하지만 query key, cache가 없다.isLoading
은 있지만 isFetcing
은 없다.(캐시가 없으니 당연하다.)isLoading
: cache된 데이터도 없고, query fn이 완료되지 않은 상태isFetcing
: cache된 데이터는 있을수도 없을수도 있고, query fn이 완료되지 않은 상태import { useMutation } from "@tanstack/react-query";
import { updatePost } from "./api";
import { Toast } from "임의의 내 컴포넌트";
export function Post(postId) {
const { mutate, isSuccess, isPending, isError, error, reset } = useMutation({
mutationFn: (postId) => updatePost(postId),
});
const handleUpdateClick = (postId) => {
mutate(postId);
if(isError) && Toast.error(error.toString());
}
...
return (
...
<button onClick={()=>handleUpdateClick(postId)}>수정하기</button>
);
}
queryClient
의 mutationCache
에 onError
메소드 설정import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {...},
queryCache: new QueryCache({
onError: (error) => {...}, // useQuery에 대한 전역 에러 처리
}),
mutationCache: new MutationCache({
onError: (error) => {...}, // useMutation에 대한 전역 에러 처리
}),
});
useIsMutating
: 아직 완료되지 않은 mutation
의 개수를 returnimport { Spinner, Text } from "@chakra-ui/react";
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
export function Loading() {
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const display = isFetching || isMutating ? "inherit" : "none";
return ( <Spinner display={display} /> );
}
mutation
실행 후 useQuery
의 데이터를 stale 상태로 바꿔주는 invalidateQueries
export function useCancelAppointment() {
const queryClient = useQueryClient();
const toast = useCustomToast();
const { mutate } = useMutation({
mutationFn: (appointment: Appointment) =>
removeAppointmentUser(appointment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [queryKeys.appointments] });
toast({ title: "취소되었습니다!", status: "success" });
},
});
return mutate;
}