React Query
React 애플리케이션에서 서버 상태(Server State)를 관리하는 라이브러리입니다.
설치
1
npm i @tanstack/react-query
주요 기능
캐싱 Cache
같은
queryKey로 호출하면 네트워크 요청 없이 캐시된 데이터 사용 가능자동 리패치 Refetching
윈도우 포커스 전환 시, 네트워크 연결 복구 시 자동으로 데이터 최신화
refetchInterval옵션으로 주기적인 폴링 가능쿼리 무효화 Invalidate
mutation(POST/PUT/DELETE)이후 관련queryKey를 무효화하여 최신 데이터 자동 반영로딩 & 에러 상태 관리
isLoading, isError, isFetching 같은 상태를 기본 제공
SSR 지원
Next.js에서 서버사이드 데이터 prefetch 후, Hydrate로 클라이언트에 전달 가능
React Query가 필요한 이유
일반적인 API 요청 방식
1 2 3 4 5 6 7 8 9 10 11 12
const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch("/api/posts") .then((res) => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, []);
로딩, 에러, 상태 업데이트 등 중복 코드가 많음.
React Query 방식
1 2 3 4 5 6
import { useQuery } from "@tanstack/react-query"; const { data, error, isLoading } = useQuery({ queryKey: ["posts"], queryFn: () => fetch("/api/posts").then((res) => res.json()), });
단 몇 줄로 데이터 가져오기 + 캐싱 + 로딩/에러 관리 가능.
Hook
useQuery
기본 데이터 조회 처리 (읽기 전용)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useQuery } from "@tanstack/react-query";
import { getUserInfo } from "@/lib/api/user";
export function Profile() {
const { data, isLoading, error } = useQuery({
queryKey: ["user"],
queryFn: getUserInfo,
staleTime: 1000 * 60, // 1분 동안 fresh
});
if (isLoading) return <p>로딩중...</p>;
if (error) return <p>에러 발생!</p>;
return <p>안녕하세요, {data.name}님</p>;
}
useMutation
데이터 변경 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addTodo } from "@/lib/api/todo";
export function AddTodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
// todo 목록 쿼리 무효화 → 자동 리패치
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: "새로운 할 일" })}
disabled={mutation.isPending}
>
{mutation.isPending ? "추가중..." : "추가하기"}
</button>
);
}
useInfiniteQuery
페이지네이션 API + 무한 스크롤 UI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useInfiniteQuery } from "@tanstack/react-query";
import { getPosts } from "@/lib/api/post";
export function PostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) => getPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage ?? false,
});
return (
<div>
{data?.pages.flatMap((page) =>
page.items.map((post) => <p key={post.id}>{post.title}</p>)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "불러오는 중..." : "더보기"}
</button>
)}
</div>
);
}
useIsFetching, useIsMutating
전역 로딩 상태 제어
1
2
3
4
5
6
7
8
9
10
11
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
function GlobalLoading() {
const isFetching = useIsFetching();
const isMutating = useIsMutating();
if (isFetching || isMutating) {
return <div className="overlay">로딩중...</div>;
}
return null;
}
HydrationBoundary
Next.js 서버에서 prefetch → 클라이언트에서 재사용 (SSR + Hydration)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// layout.tsx (서버 컴포넌트)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import getQueryClient from "@/lib/queryClient";
import { prefetchUser } from "@/lib/api/user";
export default async function RootLayout({ children }) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ["user"],
queryFn: prefetchUser,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}
useQueryClient
useQueryClient 훅은 React Query에서 핵심적인 역할을 합니다.
React Query의 전역 상태 관리자(queryClient 인스턴스)를 React 컴포넌트 안에서 접근할 수 있게 해주는 훅입니다.
QueryClientProvider로 감싸진 곳에서만 사용 가능- 반환값은
queryClient 객체이며, 이 객체에는 캐시/요청을 직접 제어하는 메서드들이 들어 있음
사용 구조
- 최상단에서
QueryClientProvider로queryClient공급
1
2
3
4
5
6
7
8
9
10
// app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
- 컴포넌트에서
useQueryClient()로 접근
1
2
3
4
5
6
7
8
9
10
11
import { useQueryClient } from "@tanstack/react-query";
function MyComponent() {
const queryClient = useQueryClient(); // 중앙 queryClient 가져오기
const refreshTodos = () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
};
return <button onClick={refreshTodos}>할 일 새로고침</button>;
}
queryClient 주요 메서드
조회 계열
getQueryData(queryKey): 특정 queryKey의 캐시 데이터를 반환 (undefined 가능)getQueriesData(filter): 여러 쿼리의 캐시 데이터를 배열로 반환1
const allTodos = queryClient.getQueriesData({ queryKey: ["todos"] });
getQueryState(queryKey): 해당 쿼리의 상태(status, fetchStatus, dataUpdatedAt 등 메타데이터)를 반환1 2
const state = queryClient.getQueryState(["todos"]); console.log(state?.status); // 'pending' | 'success' | 'error'
수정 계열
setQueryData(queryKey, updater): 캐시 데이터를 직접 수정setQueriesData(filter, updater): 여러 쿼리 데이터를 한 번에 수정
무효화 & 리패치 계열
invalidateQueries(filter): 캐시 무효화 후, 관련 쿼리들을 재요청로그아웃, CRUD 후 갱신 시 유용
refetchQueries(filter): 무효화하지 않고, 즉시 다시 요청1
await queryClient.refetchQueries({ queryKey: ["todos"] });
resetQueries(filter): 해당 쿼리를 초기 상태로 되돌림 (캐시 삭제 + 재요청 대기 상태)
취소 계열
cancelQueries(filter): 진행 중인 요청 취소
삭제 계열
removeQueries(filter): 특정 쿼리를 완전히 제거 (캐시까지 삭제)clear(): 모든 쿼리와 캐시 초기화 (앱 전체 초기화 수준)
프리패치 & 유틸 계열
fetchQuery(queryKey, queryFn, options?): 미리 데이터를 가져와 캐시에 저장 후 데이터를 직접 받음1 2 3 4 5 6
const user = await queryClient.fetchQuery({ queryKey: ["user"], queryFn: fetchUserWithRefresh, staleTime: 1000 * 60 * 5, // 5분 동안 fresh }); setUser(user); // Zustand에 동기화
prefetchQuery(queryKey, queryFn, options?): 데이터를 가져와 캐시에만 저장 (결과 반환 ❌)SSR/SSG에서 HydrationBoundary에 넘길 때 주로 사용
1 2 3 4 5
await queryClient.prefetchQuery({ queryKey: ["todos"], queryFn: fetchTodos, staleTime: 1000 * 60, // 1분 });
ensureQueryData(queryKey): 캐시에 데이터가 있으면 사용, 없으면 자동 fetch 후 캐시에 저장 + 결과 반환fetchQuery + 캐시 체크 결합판
1 2 3 4
const user = await queryClient.ensureQueryData({ queryKey: ["user"], queryFn: fetchUserWithRefresh, });
isFetching(filter?): 현재 진행 중인 fetch 개수 반환1 2 3
if (queryClient.isFetching({ queryKey: ["todos"] })) { console.log("todos 불러오는 중..."); }
isMutating(filter?): 현재 진행 중인 mutation 개수 반환
옵션 최적화
훅에서는 옵션(staleTime, cacheTime, enabled, retry, select)을 직접 넣어 데이터 흐름을 제어
메서드에서는 옵션을 인자로 넣어 캐시 조작, prefetch, 무효화 전략을 세밀하게 제어
staleTime: 데이터를 fresh 상태로 유지할 시간cacheTime: 비사용 상태로 바뀐 후, 캐시를 메모리에 보관하는 시간1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 훅 useQuery({ queryKey: ["user", id], queryFn: fetchUser, staleTime: 1000 * 60, // 1분 동안 fresh cacheTime: 1000 * 60 * 30, // 사용 안 되면 30분 후 가비지 컬렉션 }); // 메서드 queryClient.prefetchQuery({ queryKey: ["todos"], queryFn, staleTime: 1000 * 60, }); queryClient.setQueryDefaults(["todos"], { cacheTime: 1000 * 60 * 5 });
enabled: 쿼리 자동 실행을 켜/끄는 boolean, 의존 변수가 준비될 때까지 fetch를 막음훅 전용 옵션
1 2 3 4 5
useQuery({ queryKey: ["user", userId], queryFn: () => fetch(`/api/users/${userId}`), enabled: Boolean(userId), // userId가 있어야만 실행 });
retry,retryDelay: 쿼리/뮤테이션이 실패했을 때 몇 번 재시도할지 또는 조건부 재시도를 어떻게 할지 설정1 2 3 4 5 6 7 8 9 10
// 훅 useQuery({ queryKey: ["profile"], queryFn: fetchProfile, retry: 2, // 실패 시 총 2번 재시도 (기본은 0~3 등 버전별 다름) retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // 지수백오프 }); // 메서드 useMutation({ mutationFn, retry: 1 });
select: 원본 data에서 필요한 부분만 골라 컴포넌트에 전달1 2 3 4 5 6 7 8 9
// 훅 useQuery({ queryKey: ["posts"], queryFn: fetchPosts, select: (data) => data.items.map((i) => ({ id: i.id, title: i.title })), }); // 메서드 queryClient.setQueryData(["todos"], (old) => old.map((todo) => todo.title));
예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
const fetchTodos = async () => {
const { data } = await axios.get("/api/todos");
return data;
};
function TodoList() {
const { data, error, isLoading } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1분 동안은 fresh → 재요청 안 함
cacheTime: 1000 * 60 * 5, // 5분 지나면 캐시 삭제
enabled: true, // false면 아예 자동 실행 안 함
retry: 2, // 실패 시 2번만 재시도
select: (data) => data.map((todo) => todo.title), // 데이터 변환
});
if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>에러 발생!</p>;
return (
<ul>
{data?.map((title, idx) => (
<li key={idx}>{title}</li>
))}
</ul>
);
}