react-query는 react 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 보다 쉽게 다룰 수 있도록 도와주는 라이브러리이다. 클라이언트 상태와 서버 상태를 명확히 구분하기 위해 만들어졌다.
npm install @tanstack/react-query @tanstack/react-query-devtools
🔒 시나리오
// staleTime: 5초, gcTime: 10초 useQuery({ queryKey: ['data'], queryFn: fetchData, staleTime: 5000, gcTime: 10000 })
0초: 데이터 fetch, fresh 상태
5초: 데이터가 stale 상태로 변경 (아직 메모리에 있음)
페이지 이동: 쿼리 unmount → 이 시점부터 gcTime 카운트 시작
gcTime(10초) 이내 재방문: 캐시 데이터 사용 + 백그라운드 refetch
gcTime(10초) 이후 재방문: 캐시 없음, 새로 fetch
- gcTime은 쿼리가 비활성화된 후부터 시작되는 타이머이다!
staleTime: 데이터가 신선하다고 간주되는 시간
- 데이터가 성공적으로 패칭된 직후부터 시작
gcTime: 비활성화된 캐시 데이터가 메모리에 유지되는 시간가비지 콜렉션 시간
- 쿼리가 비활성화될 때 시작 ex) 컴포넌트 언마운트
요점
staleTime
- 이 시간 동안은 같은 쿼리가 호출되어도 재요청
refresh하지 않는다.- 시간이 지나면 stale
오래된상태가 된다. stale 상태에서는 쿼리가 재호출될 때 refreash가 발생한다.gcTime
- 데이터가 stale 상태여도 gcTime 동안은 캐시에 유지된다.
즉, 데이터가 stale 상태여도 일단 캐시된 데이터를 먼저 보여주고, 백그라운드에서 새로운 데이터를 가져오는 것이다..!
ex) 차량상세페이지: 사용자가 다른 차량을 보다가 뒤로가기로 돌아올 수 있다. 뒤로가기 시 빠른 로딩 가능
쿼리 인스턴스 마운트와 초기 데이터 패칭
1. A라는 queryKey를 가진 A 쿼리 인스턴스가 mount된다.
2. 네트워크에서 데이터를 fetch하고, 불러온 데이터는 A라는 queryKey로 캐시에 저장된다.
데이터 신선도 관리(fresh -> stale)
3. 이 데이터는 fresh상태에서 staleTime기본값 0 이후에 stale 상태로 변경된다.
쿼리 비활성화와 가비지 콜렉션
4. A 쿼리 인스턴스가 unmount되면 비활성 상태가 된다. ex) 페이지 이동
5. 캐시는 gcTime기본값 5분 만큼 유지되다가 가비지 콜렉터(GC)가 데이터를 메모리에서 제거한다.
캐시 재사용
6. 만약 gcTime 이내에 A 쿼리 인스턴스fresh한 상태가 새롭게 mount되면 캐시 데이터를 보여준다.
stale한 상태라면 백그라운드에서 refetch를 수행한다.v5부터 인자로 단 하나의 객체만 받는다.
queryKey, queryFn// 실제 예제
// 💡 queryFn의 반환 타입을 지정해주면 useQuery의 타입 추론이 원활합니다.
const getAllSuperHero = async (): Promise<AxiosResponse<Hero[]>> => {
return await axios.get("http://localhost:4000/superheroes");
};
const { data, isLoading } = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
});
쿼리 캐시 관리를 위한 고유 식별자로, 쿼리 함수에 편리하게 전달하는 역할도 한다(예시 참조).
const getSuperHero = async ({
queryKey,
}: {
queryKey: ["super-hero", number];
}): Promise<AxiosResponse<Hero>> => {
const heroId = queryKey[1]; // ex) queryKey: ["super-hero", "3"]
return await axios.get(`http://localhost:4000/superheroes/${heroId}`);
};
const useSuperHeroData = (heroId: string) => {
return useQuery({
queryKey: ["super-hero", heroId],
queryFn: getSuperHero, // (*)
});
};
데이터를 가져오는 실제 함수
Promise를 반환하는 함수를 넣어야 한다.// 상단의 queryKey 예제와 반대로 queryFn 자체적으로 인자를 받는 형태
const getSuperHero = async (heroId: string): Promise<AxiosResponse<Hero>> => {
return await axios.get(`http://localhost:4000/superheroes/${heroId}`);
};
const useSuperHeroData = (heroId: string) => {
return useQuery({
queryKey: ["super-hero", heroId],
queryFn: () => getSuperHero(heroId), // (*)
});
};
const useSuperHeroData = (heroId: string) => {
return useQuery({
queryKey: ["super-hero", heroId],
queryFn: () => getSuperHero(heroId),
gcTime: 5 * 60 * 1000, // 5분
staleTime: 1 * 60 * 1000, // 1분
retry: 1,
// ... options
});
};
staleTime, gcTime
fetch이 일어나지 X는다.기본값 5분은 SSR 환경에서는 Infinity이다.💡 staleTime을 gcTime보다 길게 설정했다면, staleTime만큼의 캐싱을 기대했을 때 원하는 결과를 얻지 못할 것이다. staleTime < gcTime
Polling
실시간 웹을 위한 기법으로 "일정한 주기(특정한 시간)"를 가지고 서버와 응답을 주고받는 방식이 폴링 방식이다.
refetchInterval은 시간(ms)를 값으로 넣어주면 일정 시간마다 자동으로 refetch 해준다.refetchIntervalInBackground(boolean)는 refetchInterval과 함께 사용하는 옵션으로, 탭/창이 백그라운드에 있는 동안 refetch 시켜준다.enabledboolean
쿼리가 자동으로 실행되지 않도록 할 때 설정한다.false
pending 상태로 시작한다.refetch()함수를 통해 수동으로 데이터를 다시 요청해야 한다.enabled: !!userId // userId가 있을 때만 실행를 통한 요청 시도할 경우select
쿼리 함수에서 반환된 데이터의 일부를 변환하거나 선택할 수 있다.
const {
data,
// ...
} = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
select: (data) => {
const superHeroNames = data.data.map((hero) => hero.name);
return superHeroNames;
},
});
return (
<div>
{data.map((heroName, idx) => (
<div key={`${heroName}-${idx}`}>{heroName}</div>
))}
</div>
);
placeholderData
쿼리가 pending 상태인 동안 특정 쿼리에 대한 placeholder data로 사용된다. placeholderData는 캐시에 유지되지 X으며, 서버 데이터와 관계없는 보여주기용 가짜 데이터이다.
페이지네이션를 가져올 때 목록이 깜빡거리는 현상 방지const placeholderData = useMemo(() => generateFakeHeroes(), []);
const {
data,
// ...
} = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
placeholderData: placeholderData,
});
import { useQuery } from "@tanstack/react-query";
const {
data,
// ...
} = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
placeholderData: (previousData, previousQuery) => previousData,
});
const {
data,
error,
status,
fetchStatus,
isLoading,
isFetching,
isError,
refetch,
// ...
} = useQuery({
queryKey: ["super-heroes"],
queryFn: getAllSuperHero,
});
status : 쿼리 결과값에 대한 상태data가 있는지 없는지. 문자열 형태로 3가지의 값 존재
{ enabled: false } 상태로 쿼리가 호출되면 이 상태로 시작된다.Dependent Queries 공식 문서userIdfetchStatus : QueryFn에 대한 정보를 나타낸다.queryFn 요청이 진행 중인지 아닌지
isLoading : 캐싱된 데이터가 없을 경우 즉, 처음 실행된 쿼리일 때 로딩 여부에 따라 true/false를 반환한다.
isFetching : 캐싱된 데이터가 있더라도 쿼리가 실행되면 로딩 여부에 따라 true/false를 반환한다.
isSuccess : 쿼리 요청 성공 시 true
isError : 쿼리 요청 중 에러 발생 시 true
refetch : 쿼리를 수동으로 다시 가져오는 함수
쿼리 여러 개를 동시에 수행하기 위해 사용한다.
const queryResults = useQueries({
queries: [
{
queryKey: ["super-hero", 1],
queryFn: () => getSuperHero(1),
staleTime: Infinity, // 다음과 같이 option 추가 가능!
},
{
queryKey: ["super-hero", 2],
queryFn: () => getSuperHero(2),
staleTime: 0,
},
// ...
],
쿼리에 대한 초기 데이터가 필요하기 전에 캐시를 제공하는 방법으로, 쿼리를 미리 채우는 데 사용할 수 있다.
ex) 목록 데이터가 있으면 상세 페이지 진입 시 즉시 데이터 표시 가능
const useSuperHeroData = (heroId: string) => {
const queryClient = useQueryClient();
return useQuery({
queryKey: ["super-hero", heroId],
queryFn: () => getSuperHero(heroId),
initialData: () => {
// 캐시된 히어로 목록 데이터 조회
const queryData = queryClient.getQueryData(["super-heroes"]) as any;
// 목록에서 특정 히어로 찾기
const hero = queryData?.data?.find(
(hero: Hero) => hero.id === parseInt(heroId)
);
// 찾은 경우 초기 데이터로 제공
if (hero) return { data: hero };
},
});
};
사용자 경험을 위해 데이터를 미리 받아와서 캐싱해 놓으면, 새로운 데이터를 받기 전에 사용자가 캐싱된 데이터를 볼 수 있다.
ex) 페이지네이션, 무한 스크롤
const prefetchNextPosts = async (nextPage: number) => {
const queryClient = useQueryClient();
// 해당 쿼리의 결과는 일반 쿼리들처럼 캐싱 된다.
await queryClient.prefetchQuery({
queryKey: ["posts", nextPage],
queryFn: () => fetchPosts(nextPage),
// ...options
});
};
// 단순 예
useEffect(() => {
const nextPage = currentPage + 1;
if (nextPage < maxPage) {
prefetchNextPosts(nextPage);
}
}, [currentPage]);
Infinite Queries도 prefecth 할 수 있다.pages 옵션을 활용해야 한다. 이 경우에는 getNextPageParam 함수를 무조건 제공해 줘야 한다.const prefetchTodos = async () => {
await queryClient.prefetchInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // prefetch the first 3 pages
});
};
무한 쿼리는 무한 스크롤이나 더보기 같이 특정 조건에서 데이터를 추가적으로 받아오는 기능을 구현할 때 사용하면 유용하다. 자세한 내용
useInfiniteQueryimport { useInfiniteQuery } from "@tanstack/react-query";
// 페이지 파라미터를 받는 fetch 함수
// useInfiniteQuery의 queryFn의 매개변수는 `pageParam`이라는 프로퍼티를 가질 수 있다.
const fetchColors = async ({
pageParam, // 현재 페이지 번호를 받음
}: {
pageParam: number;
}): Promise<AxiosResponse<PaginationColors>> => {
return await axios.get(`http://localhost:4000/colors?page=${pageParam}`);
};
const InfiniteQueries = () => {
const {
data, // 페이지별 데이터 배열
hasNextPage, // 가져올 수 있는 다음 페이지가 있을 경우 true
isFetching, // 데이터 패칭중 여부
isFetchingNextPage, // fetchNextPage 메서드가 다음 페이지를 가져오는 동안 true
fetchNextPage // 다음 페이지를 fetch 할 수 있다.
} = useInfiniteQuery({
queryKey: ["colors"],
queryFn: fetchColors,
initialPageParam: 1, // 첫 페이지를 가져올 때 사용할 기본 페이지 매개변수
getNextPageParam: (lastPage, allPages) => { // fetch 해온 가장 최근 페이지 목록, 현재까지 가져온 모든 페이지 데이터
return allPages.length < 4 && allPages.length + 1;
},
// ...
});
return (
<div>
{data?.pages.map((group, idx) => ({
/* ... */
}))}
<div>
<button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
LoadMore
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? "Fetching..." : null}</div>
</div>
);
};
기본적으로 서버에서 데이터를 get 할 때는 useQuery를 사용한다.
서버의 데이터를 post, patch, put, delete와 같이 수정하고자 하면 useMutation을 사용한다.
mutateAsync를 사용해서 얻어올 수 있다.const mutation = useMutation({
mutationFn: createTodo, // 3. 실제 API 호출
onMutate() { // 2. mutaion 함수가 실행되기 전에 실행된다.
/* ... */
},
onSuccess(data) {
console.log(data);
},
onError(err) {
console.log(err);
},
onSettled() { // try...catch...finally 구문의 finally처럼 요청이 성공하든 에러가 발생하든 상관없이 마지막에 실행된다.
/* ... */
},
});
const onCreateTodo = (e) => {
e.preventDefault();
mutation.mutate({ title }); // 1.
// 이떄 title은 createTodo(mutationFn) 함수로 전달된다.
};
ex) 게시판 목록에서 어떤 게시글을 작성 or 제거 했을 때 화면에 보여주는 게시판 목록을 실시간으로 최신화해야 할 경우
query Key가 변하지 않으므로 강제로 쿼리를 무효화하고 최신화를 진행해야 하는데, 이런 경우에 invalidateQueries() 메소드를 이용할 수 있다.
즉, query가 오래되었다는 것을 판단하고 다시 refetch를 할 때 사용한다!
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const useAddSuperHeroData = () => {
const queryClient = useQueryClient();
return useMutation(addSuperHero, {
onSuccess(data) {
queryClient.invalidateQueries({ queryKey: ["super-heroes"] }); // 이 key에 해당하는 쿼리가 무효화!
// queryKey에 "super-heroes"를 포함하는 모든 쿼리가 무효화된다.
console.log(data);
},
onError(err) {
console.log(err);
},
});
};
낙관적 업데이트란 서버 업데이트 시 UI에서도 어차피 업데이트할 것이라고낙관적인 가정해서 미리 UI를 업데이트 시켜주고, 서버를 통해 검증받고 업데이트 or 롤백하는 방식이다.
ex) 좋아요 버튼
setQueryData : React Query의 캐시를 직접 업데이트하는 메서드const useAddSuperHeroData = () => {
const queryClient = useQueryClient();
return useMutation({
mutateFn: addSuperHero,
onMutate: async (newHero: any) => {
// 진행 중인 요청 취소
await queryClient.cancelQueries(["super-heroes"]); // 쿼리를 수동으로 취소
// 이전 값(현재 상태 백업)
const previousHeroData = queryClient.getQueryData(["super-heroes"]);
// 새로운 값으로 낙관적 업데이트 진행
// UI 즉시 업데이트 (서버 응답 대기 없이)
queryClient.setQueryData(["super-heroes"], (oldData: any) => {
return {
...oldData,
data: [
...oldData.data,
{ ...newHero, id: oldData?.data?.length + 1 },
],
};
});
// 값이 들어있는 context 객체를 반환 (롤백을 위해 이전 데이터 반환)
return { previousHeroData };
},
// mutation이 실패하면 onMutate에서 반환된 context를 사용하여 롤백 진행(에러 발생 시 이전 상태로 복원)
onError(error, hero, context: any) {
queryClient.setQueryData(["super-heroes"], context.previousHeroData);
},
// 오류 또는 성공 후에는 항상 refetch
onSettled() {
queryClient.invalidateQueries(["super-heroes"]);
},
});
};
react-error-boundary와 useQueryErrorResetBoundary를 결압해 선언적으로 에러가 발생했을 때 Fallback UI를 보여줄 수 있다.
useQueryErrorResetBoundary 훅을 호출해 reset 함수를 가져온다.import { useQueryErrorResetBoundary } from "@tanstack/react-query"; // (*)
import { ErrorBoundary } from "react-error-boundary"; // (*)
interface Props {
children: React.ReactNode;
}
const QueryErrorBoundary = ({ children }: Props) => {
const { reset } = useQueryErrorResetBoundary(); // (*)
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
Error!!
<button onClick={() => resetErrorBoundary()}>Try again</button>
</div>
)}
>
{children}
</ErrorBoundary>
);
};
export default QueryErrorBoundary;
{throwOnError: true}를 추가해야 한다.import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import QueryErrorBoundary from "./components/ErrorBoundary"; // (*)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true, // (*) 여기서는 글로벌로 세팅했지만, 개별 쿼리로 세팅 가능
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<QueryErrorBoundary>{/* 하위 컴포넌트들 */}</QueryErrorBoundary>
</QueryClientProvider>
);
}