React Query / TanStack Query 정리본
기준: @tanstack/react-query v5
1. React Query의 기본 관점
React Query는 서버 상태(server state) 를 관리하는 도구입니다.
서버 상태
→ API로 가져오는 데이터
→ 내가 직접 소유하지 않는 데이터
→ 언제든 서버에서 바뀔 수 있는 데이터
예:
게시글 목록
댓글 목록
사용자 정보
검색 결과
상품 목록
React Query가 해주는 일:
데이터 가져오기
캐싱
로딩/에러 상태 관리
중복 요청 제거
백그라운드 refetch
stale/fresh 판단
무한 스크롤
mutation 이후 캐시 갱신
SSR hydration
서버 상태 vs 클라이언트 상태:
클라이언트 상태 (useState, zustand, redux 등)
→ 내 앱이 소유하고 내가 동기적으로 통제
서버 상태 (React Query)
→ 비동기, 원격, 누가 언제 바꿀지 모름
→ 캐싱/동기화/무효화가 핵심 관심사
2. 셋업 — QueryClient / QueryClientProvider
React Query를 쓰려면 앱 최상단에 QueryClient를 만들고 QueryClientProvider로 감쌉니다.
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
retry: 1,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Root />
</QueryClientProvider>
);
}
핵심:
QueryClient
→ 캐시를 들고 있는 객체
QueryClientProvider
→ 하위 컴포넌트가 queryClient에 접근하게 해줌
defaultOptions
→ 모든 query/mutation에 적용될 기본값
useQueryClient() 훅으로 어디서든 꺼낼 수 있습니다.
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['todos'] });
SSR에서는 요청마다 새 QueryClient를 만들어야 합니다(사용자 간 데이터 격리).
3. useQuery — 가장 기본
useQuery는 서버에서 데이터를 조회할 때 쓰는 훅입니다.
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPending) return <div>로딩 중...</div>;
if (isError) return <div>에러: {error.message}</div>;
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
필수 두 가지:
queryKey
→ 이 데이터를 캐시에서 식별할 키
queryFn
→ 실제 데이터를 가져오는 함수 (Promise 반환)
useQuery가 돌려주는 주요 값 (v5 기준)
data → 성공 응답 데이터
error → 실패 시 에러 객체
isPending → 아직 데이터가 없고 fetch 중 (v4의 isLoading)
isLoading → isPending && isFetching (초기 hard loading)
isFetching → 백그라운드 포함, 어떤 종류든 fetch 중
isSuccess → 데이터 있음
isError → 에러 상태
isStale → 캐시 데이터가 stale 상태인지
status → 'pending' | 'error' | 'success'
fetchStatus → 'fetching' | 'paused' | 'idle'
refetch() → 수동 재조회
핵심 구분:
status
→ 데이터를 갖고 있느냐 (pending/success/error)
fetchStatus
→ 지금 네트워크가 일어나고 있느냐 (fetching/paused/idle)
예시:
캐시에 데이터 있음 + 백그라운드 refetch 중
→ status: 'success'
→ fetchStatus: 'fetching'
→ isFetching: true, isPending: false
이 구분을 알면 "데이터는 보여주면서 살짝 로딩 인디케이터"를 쉽게 만들 수 있습니다.
{isFetching && <SmallSpinner />}
<TodoList todos={data} />
4. Query Keys
query key는 이 데이터를 식별하는 고유 ID 입니다. 배열로 씁니다.
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
useQuery({
queryKey: ['todos', { status, page }],
queryFn: () => fetchTodos({ status, page }),
});
규칙
1. queryKey가 같으면 같은 캐시 엔트리를 공유
2. queryKey가 바뀌면 React Query는 새 데이터로 간주하고 다시 fetch
3. queryFn에서 쓰는 모든 변수는 queryKey에 포함해야 함
→ 그래야 변수 바뀔 때 자동으로 다시 fetch됨
['todos', { page: 1 }]처럼 객체를 키에 넣어도 됩니다. 내부적으로 deterministic하게 직렬화됩니다(키 순서 달라도 같게 취급).
prefix 매칭
invalidateQueries, removeQueries 같은 캐시 조작은 기본적으로 prefix 매칭 입니다.
queryClient.invalidateQueries({ queryKey: ['todos'] });
이건 다음을 모두 매칭합니다.
['todos']
['todos', 1]
['todos', { page: 1 }]
['todos', 'detail', 3]
정확히 하나만 잡으려면:
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true,
});
Query Key Factory 패턴
규모가 커지면 key를 한 곳에서 관리합니다.
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: Filters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
장점:
- 오타 방지
- invalidate 범위 조절 쉬움 (예: todoKeys.lists()만 무효화)
- 리팩토링 안전
5. Query Function
queryFn은 Promise를 반환하는 함수입니다.
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
context 인자
queryFn은 인자로 { queryKey, signal, meta, pageParam } 같은 context를 받습니다.
useQuery({
queryKey: ['todo', todoId],
queryFn: async ({ queryKey, signal }) => {
const [, id] = queryKey;
const res = await fetch(`/todos/${id}`, { signal });
if (!res.ok) throw new Error('Network error');
return res.json();
},
});
에러는 throw로 알림
React Query는 Promise가 reject되거나 throw되면 에러로 판단 합니다.
queryFn: async () => {
const res = await fetch('/todos');
if (!res.ok) {
throw new Error('Failed');
}
return res.json();
};
fetch는 4xx/5xx에서도 reject하지 않으므로 직접 throw해야 합니다. axios는 reject하므로 그대로 둬도 됩니다.
6. enabled — 조건부 쿼리
특정 조건이 만족돼야 fetch하고 싶을 때.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
enabled가 false면:
queryFn 실행 안 함
status는 'pending' 유지
isPending: true, fetchStatus: 'idle'
Dependent Queries (의존 쿼리)
A의 결과로 B를 가져와야 하는 경우.
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => fetchUserByEmail(email),
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user.id),
enabled: !!user?.id,
});
흐름:
user fetch 끝 → user.id 생김 → enabled true → projects fetch
7. select — 데이터 변환
select로 캐시 원본은 그대로 두고, 컴포넌트에서 쓸 모양만 가공할 수 있습니다.
const { data: todoTitles } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.map((t) => t.title),
});
특징:
- 캐시에 저장되는 데이터는 원본
- select 결과만 컴포넌트로 전달
- select 함수가 같은 input에 대해 같은 output을 내면 (referential equality)
불필요한 리렌더 방지됨
같은 캐시에서 컴포넌트마다 다른 모양을 뽑아 쓸 수 있다는 게 강점입니다.
8. staleTime과 gcTime
staleTime
staleTime은 데이터를 얼마 동안 fresh로 볼 것인가 입니다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60,
});
의미:
fetch 성공 후 1분 동안은 fresh
그동안 새로 마운트되거나 window focus가 돌아와도 보통 refetch 안 함
핵심:
fresh
→ 아직 믿을 만한 데이터
stale
→ 캐시에 있긴 하지만 최신인지 다시 확인할 필요가 있는 데이터
중요한 점:
stale이라고 해서 못 쓰는 데이터가 아님
stale이어도 캐시에 있으면 일단 화면에 보여줄 수 있음
즉:
staleTime: 0
→ 데이터를 받자마자 stale
→ 하지만 캐시 데이터는 있음
→ 화면에는 바로 보여주고 백그라운드 refetch 가능
gcTime
gcTime은 inactive query를 캐시에 얼마 동안 보관할 것인가 입니다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 5,
});
의미:
해당 쿼리를 쓰는 컴포넌트가 모두 사라짐
→ inactive 상태
→ 5분 동안 캐시에 보관
→ 5분 안에 다시 마운트되면 캐시 사용
→ 5분 지나면 캐시 삭제
v4에서는 cacheTime, v5에서는 gcTime이라고 부릅니다.
정리:
staleTime → 데이터의 신선도
gcTime → 캐시 보관 시간
9. Refetch 옵션들
언제 자동으로 refetch가 일어나는가는 보통 다음 옵션으로 제어합니다.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: false,
});
refetchOnMount
true → 컴포넌트 마운트 시 stale이면 refetch
false → 마운트 시 refetch 안 함 (캐시만 보여줌)
'always' → fresh여도 무조건 refetch
refetchOnWindowFocus
true → 탭/창 포커스 돌아오면 stale 쿼리 refetch
false → 끔
'always' → 무조건 refetch
기본 true. SaaS UI에서 정말 유용한데, 로그/대시보드 같은 곳에선 자주 끕니다.
refetchOnReconnect
오프라인 → 온라인 복귀 시 refetch.
refetchInterval
폴링.
refetchInterval: 5000;
5초마다 자동 refetch.
refetchInterval: (query) =>
query.state.data?.status === 'done' ? false : 3000;
조건에 따라 멈출 수도 있음.
refetchIntervalInBackground: true로 두면 탭이 백그라운드일 때도 폴링.
수동 refetch
const { refetch } = useQuery({ ... });
<button => refetch()}>새로고침</button>
10. retry / 에러 처리
retry
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
retry: 3 → 실패 시 최대 3번 재시도
retry: false → 재시도 안 함
retry: (n, error) → 함수로 조건 제어
함수형 예:
retry: (failureCount, error) => {
if (error.status === 404) return false;
return failureCount < 3;
};
retry 전부 실패해야 비로소 isError: true가 됩니다. 그 전에는 계속 pending입니다.
Error Boundary로 처리
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true,
});
throwOnError: true면 에러를 컴포넌트에서 throw하고 React <ErrorBoundary>가 잡습니다.
함수형도 가능:
throwOnError: (error) => error.status >= 500;
서버 5xx만 에러 바운더리로 보내는 식.
전역 에러 핸들러
QueryCache에 onError를 줄 수 있습니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
toast.error(`데이터 로딩 실패: ${error.message}`);
},
}),
});
각 컴포넌트마다 토스트 호출하지 않아도 됩니다.
11. Parallel Queries / useQueries
단순 병렬
같은 컴포넌트에서 useQuery를 두 번 부르면 자동으로 병렬 fetch입니다.
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const teams = useQuery({ queryKey: ['teams'], queryFn: fetchTeams });
동적 병렬 — useQueries
쿼리 개수가 동적일 때.
import { useQueries } from '@tanstack/react-query';
const results = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
});
const isAllLoaded = results.every((r) => r.isSuccess);
const allUsers = results.map((r) => r.data);
훅 규칙(반복문 안에서 훅 못 부름) 때문에 동적 개수일 때는 useQueries가 필요합니다.
combine 옵션으로 결과를 한 번에 합칠 수도 있습니다.
useQueries({
queries: [...],
combine: (results) => ({
data: results.map((r) => r.data),
pending: results.some((r) => r.isPending),
}),
});
12. Infinite Queries
useInfiniteQuery는 페이지를 계속 이어붙이는 쿼리입니다.
1페이지 가져옴
더보기 클릭
2페이지 가져옴
기존 1페이지 뒤에 2페이지 붙임
기본 형태:
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['articles'],
queryFn: ({ pageParam }) => fetchArticles(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
핵심 옵션
initialPageParam → 첫 요청에 사용할 pageParam
getNextPageParam → 다음 페이지 요청에 사용할 pageParam 결정
fetchNextPage → 다음 페이지 추가 요청
hasNextPage → 다음 페이지가 있는지 여부
isFetchingNextPage → 다음 페이지 가져오는 중인지
data 구조
{
pages: [
{ items: [...], nextPage: 2 },
{ items: [...], nextPage: 3 },
],
pageParams: [1, 2]
}
렌더링:
const articles = data?.pages.flatMap((page) => page.items) ?? [];
13. useInfiniteQuery의 refetch
fetchNextPage()와 refetch()는 다릅니다.
fetchNextPage(); // 다음 페이지 추가 조회
refetch(); // 현재 infinite query 데이터를 다시 조회
이미 1, 2, 3페이지까지 가져온 상태에서 refetch()하면 4페이지를 가져오는 게 아니라, 기존에 쌓인 페이지들을 다시 조회합니다.
첫 페이지만 다시 refetch하고 싶을 때
const handleRefresh = async () => {
queryClient.setQueryData(queryKey, (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.slice(0, 1),
pageParams: oldData.pageParams.slice(0, 1),
};
});
await refetch();
};
1. 캐시의 infinite query 데이터를 첫 페이지만 남김
2. refetch()
3. React Query는 남아 있는 1페이지만 다시 조회
14. setQueryData와 oldData
queryClient.setQueryData(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.slice(0, 1),
pageParams: oldData.pageParams.slice(0, 1),
};
});
oldData는 현재 React Query 캐시에 들어 있는 데이터입니다.
...oldData를 쓰는 이유:
기존 객체의 다른 속성을 보존하는 습관적/방어적 패턴
일반 useQuery에서도 마찬가지:
return {
...oldData,
items: newItems,
};
15. Placeholder Query Data
placeholderData는 진짜 API 응답이 오기 전까지 임시로 보여줄 데이터 입니다.
const { data, isPlaceholderData } = useQuery({
queryKey: ['article', articleId],
queryFn: () => fetchArticle(articleId),
placeholderData: {
id: articleId,
title: '불러오는 중...',
content: '',
},
});
특징:
캐시에 저장되지 않음
임시 표시용 데이터
isPlaceholderData로 구분 가능
pagination에서
import { keepPreviousData } from '@tanstack/react-query';
const { data, isPlaceholderData } = useQuery({
queryKey: ['articles', page],
queryFn: () => fetchArticles(page),
placeholderData: keepPreviousData,
});
page가 1 → 2로 바뀜
2페이지 요청 중
그동안 1페이지 데이터를 임시로 보여줌
→ 깜빡임 없음
목록 → 상세 이동
placeholderData: () => {
const articles = queryClient.getQueryData<ArticleSummary[]>(['articles']);
return articles?.find((a) => a.id === articleId);
};
목록 데이터가 상세보다 부족하면 initialData보다 placeholderData가 더 적절합니다.
16. Initial Query Data
initialData는 캐시에 처음부터 데이터를 넣고 시작하는 옵션 입니다.
const { data } = useQuery({
queryKey: ['article', articleId],
queryFn: () => fetchArticle(articleId),
initialData: initialArticle,
});
특징:
캐시에 저장됨
실제 초기 데이터로 취급됨
data가 처음부터 있음
placeholderData와 차이:
placeholderData → 임시 표시용, 캐시에 저장 안 됨
initialData → 실제 초기 데이터, 캐시에 저장됨
staleTime과의 관계
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
initialData: initialTodos,
staleTime: 0,
});
staleTime이 0이어도 initialData를 안 쓰는 게 아닙니다.
1. initialData로 먼저 화면 그림
2. staleTime 0이라 즉시 stale
3. 백그라운드 refetch
4. 서버 응답 오면 데이터 교체
initialDataUpdatedAt
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
initialData: initialTodo,
initialDataUpdatedAt: initialTodoUpdatedAt,
staleTime: 1000 * 60,
});
없으면 React Query는 "지금 들어온 데이터"로 간주합니다. 있으면 그 시각 기준으로 staleTime을 계산합니다.
목록 캐시에서 상세 초기값을 만들 때 자주 씁니다.
initialData: () =>
queryClient
.getQueryData<Todo[]>(['todos'])
?.find((todo) => todo.id === todoId),
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
17. Prefetching
Prefetching은 사용자가 실제로 보기 전에 데이터를 미리 가져와 캐시에 넣어두는 것 입니다.
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
링크 hover 시 미리 가져오기:
<a
href={`/todos/${todo.id}`}
=> {
queryClient.prefetchQuery({
queryKey: ['todo', todo.id],
queryFn: () => fetchTodo(todo.id),
staleTime: 1000 * 60,
});
}}
>
Todo 상세 보기
</a>
상세 페이지에서는 같은 queryKey를 씁니다.
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: 1000 * 60,
});
핵심:
prefetch와 useQuery의 queryKey가 같아야 캐시 재사용
prefetch의 staleTime
prefetch에 staleTime을 주면 그 시간 동안 fresh로 간주됩니다. 실제 화면 useQuery에도 같은 값을 두는 게 안전합니다.
const TODO_STALE_TIME = 1000 * 60;
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: TODO_STALE_TIME,
});
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
staleTime: TODO_STALE_TIME,
});
staleTime: 0이어도 prefetch는 의미가 있습니다.
캐시에 데이터 있음 → 화면에 바로 보여줌 → 백그라운드 refetch
18. Mutations
useMutation은 서버 데이터를 생성/수정/삭제하거나 서버 side effect를 일으킬 때 씁니다.
const mutation = useMutation({
mutationFn: async (newTodo) => {
const res = await axios.post('/todos', newTodo);
return res.data;
},
});
실행:
mutation.mutate({ title: 'Do Laundry' });
Query와 Mutation 차이
useQuery → 서버 데이터 조회
useMutation → 서버 데이터 변경
GET /todos → useQuery
POST /todos → useMutation
PUT /todos/1 → useMutation
DELETE /todos/1 → useMutation
mutation 상태 (v5)
isIdle → 아직 실행 안 됨
isPending → 실행 중 (v4의 isLoading)
isSuccess → 성공
isError → 실패
mutation.data → 성공 응답
mutation.error → 실패 에러
mutate vs mutateAsync
mutation.mutate(input); // void 반환, 콜백으로 처리
const data = await mutation.mutateAsync(input); // Promise 반환
mutateAsync는 await으로 흐름을 이어쓰기 좋지만, 에러 처리를 잊으면 unhandled rejection이 됩니다.
19. Mutation 콜백
const mutation = useMutation({
mutationFn: addTodo,
onMutate: (variables) => {
// mutationFn 실행 직전
},
onError: (error, variables, context) => {
// 실패
},
onSuccess: (data, variables, context) => {
// 성공
},
onSettled: (data, error, variables, context) => {
// 성공/실패 관계없이 마지막
},
});
성공 흐름:
mutate 호출 → onMutate → mutationFn → onSuccess → onSettled
실패 흐름:
mutate 호출 → onMutate → mutationFn → onError → onSettled
인자:
variables → mutate(...)에 넘긴 값
data → mutationFn 성공 결과
error → mutationFn 실패 에러
context → onMutate에서 return한 값
20. Mutation 이후 invalidateQueries / setQueryData
invalidateQueries
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
todo 생성 성공
→ todos 목록은 낡았을 수 있음
→ stale 처리
→ 활성 쿼리면 다시 fetch
가장 단순하고 안전한 방식.
setQueryData
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: (createdTodo) => {
queryClient.setQueryData(['todos'], (oldTodos = []) => [
...oldTodos,
createdTodo,
]);
},
});
invalidateQueries → 서버에서 다시 조회해서 맞춤
setQueryData → 내가 직접 캐시를 수정
21. Optimistic Update
낙관적 업데이트는 서버 성공을 기다리지 않고 화면을 먼저 바꾸는 것 입니다.
const mutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
newTodo,
]);
return { previousTodos };
},
onError: (_error, _newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
흐름:
1. mutation 실행
2. onMutate에서 기존 데이터 백업
3. 캐시에 새 todo를 먼저 추가
4. 화면에 즉시 반영
5. 실패하면 previousTodos로 rollback
6. 성공/실패와 관계없이 invalidateQueries로 최종 동기화
cancelQueries를 왜 하나?
await queryClient.cancelQueries({ queryKey: ['todos'] });
진행 중이던 todos refetch가 늦게 도착해서
방금 setQueryData로 넣은 optimistic update를 덮어쓰는 것 방지
→ 시간차 응답으로 인한 race condition 방어
22. mutate 호출 시 콜백
콜백은 두 군데에 줄 수 있습니다.
useMutation에 주는 콜백 (공통 처리)
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
캐시 갱신, 공통 에러 처리, 데이터 정합성
mutate 호출에 주는 콜백 (이번 호출 전용)
mutation.mutate(todo, {
onSuccess: () => {
closeModal();
toast.success('추가 완료');
},
});
모달 닫기, 토스트, 페이지 이동 같은 UI 처리
둘 다 있으면 useMutation의 콜백이 먼저, mutate 호출 콜백이 나중에 실행됩니다.
중요:
mutate 호출에 넘긴 콜백은 컴포넌트가 언마운트되면 실행되지 않을 수 있음
→ 중요한 캐시 처리는 useMutation 쪽에 두는 게 안전
23. mutation.reset()
mutation 상태를 초기화합니다.
{mutation.isError && (
<div => mutation.reset()}>에러 발생</div>
)}
error 제거, data 제거, status idle로 초기화
서버 데이터를 되돌리는 게 아니라 mutation 객체 상태만 초기화입니다.
24. Mutation Persistence / Offline Mutation
오프라인 상태에서 mutation 실행
→ 요청이 paused 상태로 남음
→ 앱이 꺼져도 저장
→ 앱 재시작 후 복구
→ 온라인 되면 이어서 실행
setMutationDefaults
queryClient.setMutationDefaults(['addTodo'], {
mutationFn: addTodo,
retry: 3,
});
이유:
dehydrate로 저장되는 것은 mutation 상태와 variables
함수 자체는 저장할 수 없음
앱 재시작 후 mutationKey를 보고 어떤 mutationFn을 실행해야 하는지 알려줘야 함
컴포넌트에서:
const mutation = useMutation({ mutationKey: ['addTodo'] });
mutation.mutate({ title: 'title' });
dehydrate / hydrate / resumePausedMutations
const state = dehydrate(queryClient); // 저장 가능한 객체로 꺼냄
hydrate(queryClient, state); // 복구
queryClient.resumePausedMutations(); // paused mutation 재실행
실무에서는 직접 구현보다 persistQueryClient를 씁니다.
persistQueryClient({
queryClient,
persister,
});
이건 "앱 시작 시 자동 저장/복구 시스템을 켜는 코드"입니다.
25. Query Cancellation
기본적으로 React Query는 사용하지 않는 쿼리를 무조건 네트워크 취소하지 않습니다.
컴포넌트 언마운트
→ 요청이 계속 진행될 수 있음
→ 응답이 오면 캐시에 저장될 수 있음
이게 나쁜 건 아닙니다. 다시 돌아왔을 때 캐시 데이터로 바로 보여줄 수 있으니까.
실제 요청 취소하려면 signal 사용
useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const res = await fetch('/todos', { signal });
return res.json();
},
});
수동 취소:
queryClient.cancelQueries({ queryKey: ['todos'] });
cancelQueries → React Query에게 이 쿼리 취소 요청
signal 사용 → 실제 네트워크 요청 abort 가능
26. Scroll Restoration
브라우저는 뒤로가기 시 스크롤 위치를 복원하려고 합니다. 하지만 SPA에서 비동기 데이터가 있으면:
뒤로가기
→ 리스트 페이지 렌더링
→ 데이터 없음
→ 높이 부족
→ 브라우저가 예전 scrollY 복원 실패
→ 데이터 나중에 도착
→ 위치가 어긋남
React Query를 쓰면:
뒤로가기
→ 캐시에 리스트 데이터 있음
→ 즉시 리스트 렌더링
→ DOM 높이가 빨리 복원
→ 브라우저 기본 스크롤 복원이 잘 작동
중요:
React Query가 직접 scrollY를 저장하거나 window.scrollTo 하는 건 아님
캐시 덕분에 브라우저의 기본 스크롤 복원이 잘 되도록 도와주는 것
잘 안 될 수 있는 경우:
내부 div 스크롤
가상 스크롤
이미지/광고로 높이가 나중에 바뀜
캐시가 사라짐
라우터가 스크롤을 강제로 top으로 보냄
27. QueryFilters / MutationFilters
필터는 캐시 안에서 어떤 query/mutation만 대상으로 할지 고르는 조건 객체 입니다.
QueryFilters
queryClient.invalidateQueries({ queryKey: ['posts'] });
기본적으로 prefix 매칭.
['posts']
['posts', 1]
['posts', { page: 1 }]
정확히 하나만:
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true,
});
자주 쓰는 필터:
queryClient.cancelQueries({ queryKey: ['todos'] });
queryClient.removeQueries({ queryKey: ['todos'], type: 'inactive' });
queryClient.refetchQueries({ type: 'active' });
queryClient.invalidateQueries({ queryKey: ['posts'], stale: true });
type:
'active' → 현재 마운트된 쿼리만
'inactive' → 마운트 안 된 쿼리만
'all' → 전부 (기본)
predicate로 임의 조건:
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'posts',
});
MutationFilters
const count = queryClient.isMutating({ mutationKey: ['post'] });
또는 렌더링에서:
const isSavingPost = useIsMutating({ mutationKey: ['post'] }) > 0;
post 관련 mutation이 하나라도 진행 중이면 true.
28. SSR / SSG에서 React Query 사용
크게 두 가지 방식:
1. initialData 방식
2. dehydrate / hydrate 방식
initialData 방식 (간단)
export async function getStaticProps() {
const posts = await getPosts();
return { props: { posts } };
}
function Posts({ posts }) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: posts,
});
}
장점/단점:
+ 간단함
- 깊은 컴포넌트까지 props 전달 필요
- 여러 쿼리가 있으면 번거로움
dehydrate / hydrate 방식 (정석)
서버:
export async function getStaticProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
클라이언트:
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
컴포넌트는 그냥 useQuery:
function Posts() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
}
장점:
캐시를 그대로 복구
깊은 자식에서도 바로 사용 가능
여러 쿼리 prefetch 좋음
29. Custom SSR Framework Hydration
Next.js가 아닌 직접 SSR 서버.
서버:
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
const dehydratedState = dehydrate(queryClient);
const html = renderToString(
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
</QueryClientProvider>
);
HTML에 상태 포함:
<script>
window.__REACT_QUERY_STATE__ = ...
</script>
클라이언트:
const dehydratedState = window.__REACT_QUERY_STATE__;
const queryClient = new QueryClient();
hydrateRoot(
document.getElementById('root'),
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
</QueryClientProvider>
);
중요:
- 서버와 클라이언트가 같은 dehydratedState로 렌더 → hydration mismatch 방지
- SSR에서는 요청마다 새 QueryClient (사용자 간 데이터 격리)
30. SSR hydration 주의사항
성공한 쿼리만 dehydrate에 포함됨
서버에서 실패
→ dehydratedState에 없음
→ 클라이언트는 처음부터 fetch 가능
서버에서 404/500을 직접 처리하고 싶으면 fetchQuery와 try/catch:
try {
await queryClient.fetchQuery({
queryKey: ['post', postId],
queryFn: () => getPost(postId),
});
} catch (error) {
return { notFound: true };
}
stale 여부는 서버 fetch 시점 기준
서버에서 10:00에 fetch
staleTime 1분
→ 10:01까지 fresh
staleTime 0이면 클라이언트에서 다시 fetch
서버에서 fetch → HTML 포함 → 클라이언트 hydrate
→ stale → 백그라운드 refetch 발생
이중 fetch 줄이려면:
new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 },
},
});
CDN 캐싱과 조합
마크업은 빠르게 제공
데이터 최신성은 백그라운드 refetch로 보완
31. 캐시 생명주기 예제
가정: staleTime: 0, gcTime: 5분.
1. useQuery(['todos']) 첫 마운트
→ 캐시 없음 → hard loading → fetch → 캐시 저장
→ staleTime 0이라 즉시 stale
2. 같은 queryKey의 두 번째 useQuery 마운트
→ 캐시 데이터 즉시 반환
→ stale이라 백그라운드 refetch
→ 같은 queryKey라 요청은 하나로 공유
3. 두 컴포넌트 모두 언마운트
→ active observer 없음 → inactive query → gcTime 타이머 시작
4. 5분 안에 다시 마운트
→ 캐시 데이터 즉시 반환
→ stale이면 백그라운드 refetch
5. 5분 동안 아무도 안 씀
→ garbage collection → 다음 마운트는 다시 hard loading
32. Default Query Function
앱 전체 기본 queryFn을 등록할 수 있습니다.
const defaultQueryFn = async ({ queryKey }) => {
const { data } = await axios.get(
`https://api.example.com${queryKey[0]}`
);
return data;
};
const queryClient = new QueryClient({
defaultOptions: {
queries: { queryFn: defaultQueryFn },
},
});
queryFn 생략 가능:
useQuery({ queryKey: ['/posts'] });
특정 쿼리에 queryFn 주면 덮어씁니다. 실무에서는 default queryFn보다 queryOptions 패턴이 깔끔합니다.
import { queryOptions } from '@tanstack/react-query';
const postsOptions = queryOptions({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 1000 * 60,
});
useQuery(postsOptions);
queryClient.prefetchQuery(postsOptions);
같은 쿼리 설정을 여러 곳에서 안전하게 재사용 가능.
33. DevTools
개발할 때 거의 필수:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
볼 수 있는 것:
- 현재 캐시에 있는 모든 query/mutation
- 각 query의 상태 (fresh/stale/fetching/paused/inactive)
- queryKey, data, error
- 수동 invalidate, refetch, remove
stale인지 fresh인지가 안 잡힐 때 가장 먼저 켭니다.
34. 최종 핵심 요약
React Query는 서버 상태를 캐싱하고,
staleTime으로 신선도를 판단하고,
gcTime으로 캐시 보관 시간을 관리한다.
useQuery는 queryKey + queryFn 한 쌍.
queryKey가 바뀌면 자동으로 다시 fetch한다.
queryFn에서 쓰는 모든 변수는 queryKey에 들어가야 한다.
status는 데이터 유무, fetchStatus는 네트워크 진행 여부.
isFetching && data 있음 → 데이터 보여주면서 백그라운드 갱신.
enabled로 조건부 쿼리, select로 변환, 둘 다 캐시는 원본 유지.
의존 쿼리는 enabled로 체이닝.
refetchOnWindowFocus, refetchOnMount, refetchInterval로
자동 refetch 시점을 제어한다.
useInfiniteQuery는 pages/pageParams 구조로 페이지를 쌓는다.
fetchNextPage는 다음 페이지 추가, refetch는 현재 쌓인 페이지들을 다시 조회.
placeholderData는 임시 표시용이고 캐시에 저장되지 않는다.
initialData는 실제 초기 데이터로 캐시에 저장된다.
prefetchQuery는 나중에 쓸 데이터를 미리 캐시에 받아두는 것.
staleTime이 0이어도 캐시 데이터가 있으면 일단 보여줄 수 있다.
useMutation은 서버 데이터를 변경한다.
성공 후 invalidateQueries로 다시 조회하거나, setQueryData로 직접 수정.
optimistic update: onMutate에서 캐시를 먼저 바꾸고,
onError에서 rollback, onSettled에서 invalidateQueries로 최종 동기화.
cancelQueries는 늦게 도착한 응답이 optimistic update를 덮어쓰지 못하게 막는다.
QueryFilters는 캐시 안에서 대상을 고르는 조건.
queryKey는 prefix 매칭, exact: true면 정확 일치.
SSR은 initialData(간단) 또는 dehydrate/hydrate(정석).
dehydrate에는 성공한 쿼리만 들어가고,
SSR에서 staleTime 0이면 클라이언트에서 다시 refetch될 수 있다.
스크롤 복원은 React Query가 직접 하는 게 아니라,
캐시 데이터가 즉시 렌더링되면서 브라우저의 기본 스크롤 복원이 잘 작동하게 되는 것.
한 문장으로:
React Query는 "서버 데이터를 가져오는 라이브러리"라기보다,
서버 데이터의 캐시 생명주기, 신선도, 동기화, 변경 후 갱신까지 관리하는
서버 상태 관리 도구다.