TanStack Query Complete Guide | React Query, Data Fetching, Caching, Mutations
이 글의 핵심
TanStack Query (React Query) simplifies data fetching in React apps with automatic caching, background updates, and smart refetching. This guide covers everything from setup to advanced patterns.
Introduction
TanStack Query (formerly React Query) is a powerful data-fetching library that handles server state management in React applications. It provides automatic caching, background updates, and a clean API that eliminates most boilerplate code.
Why TanStack Query?
Before TanStack Query:
// Manual state management
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
With TanStack Query:
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
1. Installation & Setup
Install Dependencies
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
Configure QueryClient
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
cacheTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// app/layout.tsx
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
2. useQuery: Fetching Data
Basic Query
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const { data, isLoading, error, isFetching } = useQuery<User[]>({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
},
});
if (isLoading) return <div>Loading users...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{isFetching && <div>Updating...</div>}
<ul>
{data?.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
}
Query with Parameters
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
enabled: !!userId, // Only run if userId exists
});
return <div>{user?.name}</div>;
}
Dependent Queries
function UserPosts({ userId }: { userId: number }) {
// First query
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
// Dependent query - only runs after user is loaded
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetch(`/api/posts?userId=${user.id}`).then(res => res.json()),
enabled: !!user,
});
return <div>{/* Render posts */}</div>;
}
3. useMutation: Modifying Data
Basic Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserData {
name: string;
email: string;
}
function CreateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: CreateUserData) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
return response.json();
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}
Optimistic Updates
function TodoList() {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: async ({ id, completed }: { id: number; completed: boolean }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
});
return res.json();
},
onMutate: async ({ id, completed }) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old: any) =>
old.map((todo: any) =>
todo.id === id ? { ...todo, completed } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <div>{/* Render todos */}</div>;
}
4. Infinite Queries
Pagination
function InfiniteUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['users'],
queryFn: async ({ pageParam = 1 }) => {
const res = await fetch(`/api/users?page=${pageParam}&limit=20`);
return res.json();
},
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
initialPageParam: 1,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.users.map((user: any) => (
<div key={user.id}>{user.name}</div>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more data'}
</button>
</div>
);
}
5. Advanced Patterns
Query Invalidation
const queryClient = useQueryClient();
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'], exact: false });
// Invalidate all queries
queryClient.invalidateQueries();
Prefetching
function UserListWithPrefetch() {
const queryClient = useQueryClient();
const handleMouseEnter = (userId: number) => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
};
return (
<ul>
{users.map((user) => (
<li key={user.id} onMouseEnter={() => handleMouseEnter(user.id)}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
Custom Hooks
// hooks/useUsers.ts
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
return res.json();
},
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateUserData) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
6. Configuration Options
Query Options
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
// Caching
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
// Refetching
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
refetchInterval: false,
// Retry
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
// Conditional
enabled: true,
});
7. Error Handling
Global Error Handler
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
console.error('Query error:', error);
toast.error('Failed to fetch data');
},
},
mutations: {
onError: (error) => {
console.error('Mutation error:', error);
toast.error('Failed to update data');
},
},
},
});
Per-Query Error Handling
const { data, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
onError: (error) => {
if (error instanceof Error) {
toast.error(error.message);
}
},
});
8. TypeScript Integration
import { useQuery, useMutation, type UseQueryResult } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
interface ApiError {
message: string;
code: string;
}
// Typed query
function useUser(userId: number): UseQueryResult<User, ApiError> {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw { message: 'Failed to fetch', code: 'FETCH_ERROR' };
}
return res.json();
},
});
}
9. Best Practices
1. Use Query Keys Consistently
// ❌ Bad
queryKey: ['users', userId, 'posts']
// ✅ Good
queryKey: ['users', userId, 'posts'] as const
2. Create Custom Hooks
// Reusable, testable, maintainable
export const useUsers = () => useQuery({...});
export const useCreateUser = () => useMutation({...});
3. Handle Loading States
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;
4. Use Optimistic Updates for Better UX
// Immediate feedback, rollback on error
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newData]);
return { previous };
},
10. Performance Tips
Reduce Re-renders
// ❌ Re-renders on every state change
const { data, isLoading, error, isFetching } = useQuery({...});
// ✅ Only re-render when needed
const { data } = useQuery({...});
Selective Refetching
// Only refetch when needed
queryClient.invalidateQueries({
queryKey: ['users', userId],
exact: true
});
Use Suspense (Experimental)
const { data } = useSuspenseQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// No need for isLoading check - Suspense handles it
Summary
TanStack Query transforms data fetching in React:
- Automatic caching reduces network requests
- Background refetching keeps data fresh
- Optimistic updates improve perceived performance
- DevTools make debugging easy
Key Takeaways:
- Replace useEffect + fetch with useQuery
- Use queryKey for automatic caching
- Implement optimistic updates for better UX
- Create custom hooks for reusability
- Configure staleTime and cacheTime appropriately
Next Steps:
- Explore tRPC integration for type-safe APIs
- Learn Zustand for client state
- Check React 18 features
Resources: