TanStack Query (formerly React Query) transforms how you handle server state in React. Instead of managing loading states, error handling, caching, and refetching manually, you declare what data you need and let the library handle the rest.
The problem it solves
Manual data fetching requires managing:
// Without TanStack Query
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setIsLoading(false));
}, [userId]);
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <Profile user={user} />;
}
TanStack Query simplifies this:
// With TanStack Query
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <Profile user={user} />;
}
Plus you get caching, deduplication, background refetching, and more.
Installation
npm install @tanstack/react-query
Set up the provider:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
useQuery basics
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const {
data, // The fetched data
isLoading, // First load, no data yet
isFetching, // Any fetch in progress
error, // Error object if failed
refetch, // Function to manually refetch
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// ...
}
Query keys
Query keys identify cached data. Use arrays for hierarchical keys:
// All todos
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// Todos filtered by status
useQuery({
queryKey: ['todos', { status: 'completed' }],
queryFn: () => fetchTodos({ status: 'completed' }),
});
// Single todo by ID
useQuery({
queryKey: ['todos', todoId],
queryFn: () => fetchTodo(todoId),
});
// Nested user data
useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
});
Caching and staleness
| Concept | Default | Description |
|---|---|---|
| staleTime | 0 | How long data stays fresh |
| gcTime | 5 minutes | How long unused data stays in cache |
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
- Fresh data: Won't refetch on mount or window focus
- Stale data: Shown immediately, refetch happens in background
- Garbage collected: Removed from cache, next access triggers fresh fetch
Automatic refetching
TanStack Query refetches stale data automatically:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true, // When user returns to tab
refetchOnReconnect: true, // When network reconnects
refetchOnMount: true, // When component mounts
retry: 3, // Retry failed requests
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
useMutation
Handle create, update, delete operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => createTodo(newTodo),
onSuccess: () => {
// Invalidate and refetch todos
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title: inputValue });
}}>
<input value={inputValue} onChange={...} />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
</form>
);
}
Optimistic updates
Update UI before the server confirms:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
)
);
// Return context for rollback
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Infinite queries
Load paginated data:
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteTodos() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam = 0 }) => fetchTodosPage(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
return (
<>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
);
}
Dependent queries
Fetch data that depends on other data:
function UserPosts({ userId }) {
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId),
enabled: !!userQuery.data, // Only run when user is loaded
});
}
Parallel queries
Fetch multiple resources simultaneously:
import { useQueries } from '@tanstack/react-query';
function Dashboard({ userIds }) {
const userQueries = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
});
const isLoading = userQueries.some((q) => q.isLoading);
const users = userQueries.map((q) => q.data);
}
DevTools
Add the DevTools for debugging:
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
With Next.js
In the App Router, use TanStack Query in Client Components:
// app/posts/page.tsx
import { PostList } from './PostList';
import { prefetchPosts } from '@/lib/queries';
export default async function PostsPage() {
// Optional: prefetch on server
return <PostList />;
}
// app/posts/PostList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function PostList() {
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// ...
}
Summary
TanStack Query simplifies server state management with automatic caching, deduplication, and refetching. Use useQuery for fetching, useMutation for updates, and query keys for cache organization. Configure staleTime based on data freshness needs. Invalidate queries after mutations to keep UI in sync. Add DevTools for debugging cache state.
