TanStack Query는 React 애플리케이션에서 서버 상태 관리를 위한 표준 솔루션입니다.
useQuery: 데이터 가져오기 및 캐싱
useQuery는 queryKey를 캐시 키로 사용하여 서버 상태를 구독합니다.
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Setup: wrap your app
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
retry: 3,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
);
}
// Basic query
interface User {
id: number;
name: string;
email: string;
}
function useUser(userId: number) {
return useQuery<User>({
queryKey: ['user', userId], // cache key — must be unique
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
},
enabled: userId > 0, // only run if userId is valid
staleTime: 1000 * 60 * 10, // override global default
});
}
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, isError, error } = useUser(userId);
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return <div>{data.name} — {data.email}</div>;
}useMutation: 생성, 업데이트, 삭제
useMutation은 onSuccess 콜백으로 CRUD 작업을 처리합니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreatePostInput {
title: string;
body: string;
userId: number;
}
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreatePostInput) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
onSuccess: (newPost) => {
// Invalidate and refetch posts list
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Or add directly to cache (no refetch needed)
queryClient.setQueryData(['post', newPost.id], newPost);
},
onError: (error) => {
console.error('Failed to create post:', error);
},
});
}
function CreatePostForm() {
const mutation = useCreatePost();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ title: 'New Post', body: 'Content', userId: 1 });
};
return (
<form onSubmit={handleSubmit}>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Post created!</p>}
</form>
);
}즉시 UI를 위한 낙관적 업데이트
낙관적 업데이트는 서버 응답 전에 UI에 변경을 적용합니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
}
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: Todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
}).then(r => r.json()),
// Optimistic update — runs BEFORE the mutation
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot current data for rollback
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update the cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map(todo =>
todo.id === updatedTodo.id
? { ...todo, completed: !todo.completed }
: todo
)
);
return { previousTodos }; // context for onError
},
// If mutation fails, roll back to snapshot
onError: (_err, _variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}페이지네이션을 위한 무한 쿼리
useInfiniteQuery는 커서 기반 페이지네이션을 처리합니다.
import { useInfiniteQuery } from '@tanstack/react-query';
interface Page {
items: Post[];
nextCursor?: string;
}
function useInfinitePosts() {
return useInfiniteQuery<Page>({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const url = pageParam
? `/api/posts?cursor=${pageParam}`
: '/api/posts';
return fetch(url).then(r => r.json());
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
return (
<div>
{data?.pages.flatMap(page => page.items).map(post => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
)}
</div>
);
}쿼리 키 패턴과 프리페칭
일관된 키 팩토리가 캐시 불일치를 방지합니다.
// Query key patterns — factory functions for consistency
const queryKeys = {
all: ['posts'] as const,
lists: () => [...queryKeys.all, 'list'] as const,
list: (filters: string) => [...queryKeys.lists(), { filters }] as const,
details: () => [...queryKeys.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.details(), id] as const,
};
// Usage:
useQuery({ queryKey: queryKeys.detail(1), queryFn: ... });
// Invalidate all posts:
queryClient.invalidateQueries({ queryKey: queryKeys.all });
// Invalidate only lists:
queryClient.invalidateQueries({ queryKey: queryKeys.lists() });
// Prefetching for better UX
async function prefetchUser(userId: number) {
await queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 10 * 60 * 1000, // only prefetch if > 10 min stale
});
}
// On hover: prefetch before click
<link onMouseEnter={() => prefetchUser(userId)} href={`/users/${userId}`}>
View Profile
</link>TanStack Query vs 다른 솔루션
| Feature | TanStack Query | Redux Toolkit | SWR | Apollo |
|---|---|---|---|---|
| Caching | Built-in (staleTime, gcTime) | Manual | Built-in | Built-in (Apollo-only) |
| Optimistic updates | First-class | Manual | Basic | First-class |
| Infinite query | useInfiniteQuery | Manual | useSWRInfinite | fetchMore |
| Mutations | useMutation | RTK Query | No (use fetch) | useMutation |
| DevTools | Excellent | Excellent | Basic | Good |
| REST + GraphQL | Both | Both | REST focused | GraphQL only |
모범 사례
- 일관성을 위해 쿼리 키 팩토리를 사용하세요.
- 데이터 신선도 요구사항에 따라 staleTime을 설정하세요.
- 항상 로딩 및 오류 상태를 제공하세요.
- 빠른 작업에 낙관적 업데이트를 사용하세요.
- 호버 또는 라우트 전환 시 데이터를 프리페치하세요.
자주 묻는 질문
staleTime과 gcTime의 차이는?
staleTime은 데이터가 오래됨을 판단하는 시간, gcTime은 메모리에 유지되는 시간입니다.
TanStack Query는 경쟁 상태를 어떻게 처리하나요?
동일한 요청을 자동으로 중복 제거합니다.
Redux나 Zustand와 함께 사용해야 하나요?
보완적 사용: TanStack Query는 서버 상태, Zustand는 클라이언트 상태에.
인증 오류를 전역으로 처리하려면?
QueryClient의 전역 onError 콜백을 사용하세요.
TanStack Query v5의 변경 사항은?
cacheTime이 gcTime으로 변경, queryFn 매개변수를 객체로, RSC 지원 추가.