TanStack Query: The Complete Guide to Server State Management
Master TanStack Query (React Query) with practical examples. Learn data fetching, caching, mutations, and real-world patterns.
TanStack Query: The Complete Guide to Server State Management
If you've ever struggled with loading states, cache invalidation, or keeping your UI in sync with server data, TanStack Query (formerly React Query) is about to become your best friend.
Why TanStack Query?
Traditional data fetching in React looks like this:
// The old way - managing everything yourself 😫const [data, setData] = useState(null);const [loading, setLoading] = useState(true);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:
// The TanStack Query way - clean and powerful ✨const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(res => res.json())});But it's not just about less code. TanStack Query gives you:
- Automatic caching - data is cached and shared across components
- Background refetching - keeps data fresh without loading spinners
- Stale-while-revalidate - show cached data while fetching updates
- Request deduplication - multiple components, single request
- Optimistic updates - instant UI feedback
- Retry logic - automatic retries on failure
Getting Started
Install TanStack Query:
npm install @tanstack/react-querySet up the QueryClient provider:
Query Keys: The Foundation
Query keys uniquely identify your data. They can be simple strings or complex arrays:
// Simple keyuseQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// With parametersuseQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
// With filtersuseQuery({ queryKey: ['todos', { status: 'completed', page: 1 }], queryFn: () => fetchTodos({ status: 'completed', page: 1 })});Pro tip: Structure your keys hierarchically for easy invalidation:
// All these can be invalidated with queryClient.invalidateQueries({ queryKey: ['todos'] })['todos'] // all todos['todos', 'list'] // todo list['todos', 'detail', 1] // specific todo['todos', { status: 'active' }] // filtered todosMutations: Changing Data
For creating, updating, or deleting data, use useMutation:
Optimistic Updates
Don't make users wait! Update the UI immediately and rollback if something fails:
const updateTodo = useMutation({ mutationFn: updateTodoApi, // Optimistically update before the server responds onMutate: async (newTodo) => { // Cancel in-flight queries await queryClient.cancelQueries({ queryKey: ['todos'] }); // Snapshot previous value const previousTodos = queryClient.getQueryData(['todos']); // Optimistically update queryClient.setQueryData(['todos'], (old: Todo[]) => old.map(t => t.id === newTodo.id ? newTodo : t) ); // Return context for rollback return { previousTodos }; }, // Rollback on error onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context?.previousTodos); }, // Refetch after success or error onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); }});Dependent Queries
Sometimes queries depend on each other:
// First, get the userconst { data: user } = useQuery({ queryKey: ['user', email], queryFn: () => getUserByEmail(email)});
// Then, get their projects (only when user exists)const { data: projects } = useQuery({ queryKey: ['projects', user?.id], queryFn: () => getProjectsByUser(user!.id), enabled: !!user?.id // Only run when user.id exists});Pagination & Infinite Queries
For paginated data:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: 1});
// Render all pages{data?.pages.map(page => page.posts.map(post => <Post key={post.id} {...post} />))}
// Load more button<button onClick={() => fetchNextPage()} disabled={!hasNextPage}> {isFetchingNextPage ? 'Loading...' : 'Load More'}</button>Smart Defaults & Configuration
Configure global defaults:
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // Data fresh for 5 minutes gcTime: 1000 * 60 * 30, // Keep unused data for 30 minutes retry: 3, // Retry failed requests 3 times refetchOnWindowFocus: true, // Refetch when tab regains focus refetchOnReconnect: true // Refetch when network reconnects } }});Per-query configuration:
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser, staleTime: Infinity, // Never consider stale (good for rarely-changing data) refetchOnMount: false, // Don't refetch when component mounts refetchInterval: 30000 // Poll every 30 seconds});DevTools
Don't forget the DevTools for debugging:
npm install @tanstack/react-query-devtoolsimport { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() { return ( <QueryClientProvider client={queryClient}> <MyApp /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> );}Best Practices
1. Create Custom Hooks
// hooks/useUsers.tsexport function useUsers() { return useQuery({ queryKey: ['users'], queryFn: fetchUsers });}
export function useUser(id: number) { return useQuery({ queryKey: ['users', id], queryFn: () => fetchUser(id), enabled: !!id });}2. Centralize Query Keys
// lib/queryKeys.tsexport const queryKeys = { users: { all: ['users'] as const, detail: (id: number) => ['users', id] as const, filtered: (filters: Filters) => ['users', filters] as const }, posts: { all: ['posts'] as const, byUser: (userId: number) => ['posts', { userId }] as const }};3. Handle Loading & Error States Gracefully
function UserProfile({ userId }: { userId: number }) { const { data, isLoading, isError, error, refetch } = useUser(userId); if (isLoading) return <Skeleton />; if (isError) return <ErrorCard error={error} onRetry={refetch} />; return <Profile user={data} />;}TanStack Query vs Other Solutions
| Feature | TanStack Query | SWR | Redux + RTK Query |
|---|---|---|---|
| Caching | ✅ Excellent | ✅ Good | ✅ Good |
| DevTools | ✅ Built-in | ❌ None | ✅ Redux DevTools |
| Mutations | ✅ First-class | ⚠️ Basic | ✅ First-class |
| TypeScript | ✅ Excellent | ✅ Good | ✅ Excellent |
| Bundle Size | 12KB | 4KB | 25KB+ |
| Learning Curve | Low | Very Low | Medium |
Conclusion
TanStack Query transforms how you handle server state in React. No more manual loading states, stale data, or cache invalidation headaches. It's the missing piece that makes data fetching actually enjoyable.
Start small — replace one useEffect fetch with useQuery. Once you feel the difference, you'll never go back.
---
Ready to level up? Check out the official TanStack Query docs for advanced patterns like prefetching, SSR, and testing.
Stay Updated 📬
Get the latest tips and tutorials delivered to your inbox. No spam, unsubscribe anytime.