⏱️5 min read · 1,005 words
TanStack Query (formerly React Query) is the gold standard for server state management in React, Vue, Solid, and Angular applications in 2026. It eliminates the complexity of manual data fetching, caching, and synchronization, replacing hundreds of lines of useEffect code with simple, powerful hooks.
📋 Table of Contents
Why TanStack Query?
- No boilerplate — replaces Redux + fetch + useEffect patterns
- Automatic caching — data cached by key, refreshed when needed
- Background refetching — stale data shown instantly, fresh data in background
- Loading/error states — built-in, no manual state management
- Optimistic updates — UI updates before server confirms
- DevTools — inspect cache, refetch, invalidate from browser extension
Installation and Setup
npm install @tanstack/react-query @tanstack/react-query-devtools
# Optional: axios for HTTP
npm install axios
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes (data considered fresh)
gcTime: 10 * 60 * 1000, // 10 minutes (cache garbage collection)
retry: 2, // retry failed requests twice
refetchOnWindowFocus: true, // refetch when tab becomes active
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
useQuery — Fetching Data
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
interface User {
id: number;
name: string;
email: string;
}
// Query function
async function fetchUser(id: number): Promise<User> {
const { data } = await axios.get(`/api/users/${id}`);
return data;
}
// Component using query
function UserProfile({ userId }: { userId: number }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // true when refetching in background
isStale, // true when data is stale
refetch, // manually trigger refetch
} = useQuery({
queryKey: ['users', userId], // unique cache key
queryFn: () => fetchUser(userId),
enabled: userId > 0, // only fetch when userId is valid
staleTime: 60_000, // fresh for 1 minute
select: (data) => data, // transform data before returning
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorBoundary error={error} />;
return (
<div>
{isFetching && <small>Refreshing...</small>}
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
Parallel Queries
// Fetch multiple queries simultaneously
function Dashboard() {
const [userQuery, postsQuery, statsQuery] = useQueries({
queries: [
{ queryKey: ['user', 1], queryFn: () => fetchUser(1) },
{ queryKey: ['posts'], queryFn: fetchPosts },
{ queryKey: ['stats'], queryFn: fetchStats },
],
});
if (userQuery.isLoading || postsQuery.isLoading) return <Loading />;
return (
<div>
<UserCard user={userQuery.data} />
<PostList posts={postsQuery.data} />
<StatsPanel stats={statsQuery.data} />
</div>
);
}
useMutation — Creating, Updating, Deleting
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function createPost(post: { title: string; content: string }) {
const { data } = await axios.post('/api/posts', post);
return data;
}
function NewPostForm() {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation({
mutationFn: createPost,
// Called when mutation succeeds
onSuccess: (newPost) => {
// Option 1: Invalidate cache (triggers refetch)
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Option 2: Update cache directly (no refetch)
queryClient.setQueryData(['posts'], (old: Post[]) => [...old, newPost]);
// Show toast notification
toast.success('Post created!');
},
onError: (error) => {
toast.error(`Failed: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
mutate({
title: (form.title as HTMLInputElement).value,
content: (form.content as HTMLTextAreaElement).value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" required placeholder="Title" />
<textarea name="content" required placeholder="Content" />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
{isError && <p className="error">{error.message}</p>}
</form>
);
}
Optimistic Updates
const { mutate: likePost } = useMutation({
mutationFn: (postId: number) => axios.post(`/api/posts/${postId}/like`),
onMutate: async (postId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot the previous value
const previousPost = queryClient.getQueryData(['posts', postId]);
// Optimistically update the UI
queryClient.setQueryData(['posts', postId], (old: Post) => ({
...old,
likes: old.likes + 1,
}));
// Return context for rollback
return { previousPost };
},
// Roll back on error
onError: (error, postId, context) => {
queryClient.setQueryData(['posts', postId], context?.previousPost);
toast.error('Failed to like post');
},
// Always refetch after mutation
onSettled: (data, error, postId) => {
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
Infinite Queries
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
lastPage.hasMore ? pages.length + 1 : undefined,
});
return (
<div>
{data?.pages.map((page, i) => (
<React.Fragment key={i}>
{page.posts.map(post => <PostCard key={post.id} post={post} />)}
</React.Fragment>
))}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'All loaded'}
</button>
</div>
);
}
Custom Query Hooks
// hooks/useUsers.ts — reusable query hooks
export function useUsers(filters?: UserFilters) {
return useQuery({
queryKey: ['users', filters],
queryFn: () => fetchUsers(filters),
select: (data) => ({
users: data.users,
total: data.total,
}),
});
}
export function useUser(id: number) {
const queryClient = useQueryClient();
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
// Pre-populate from list query
initialData: () => {
const usersQuery = queryClient.getQueryData<UsersResponse>(['users']);
return usersQuery?.users.find(u => u.id === id);
},
initialDataUpdatedAt: () =>
queryClient.getQueryState(['users'])?.dataUpdatedAt,
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (user: Partial<User> & { id: number }) =>
axios.patch(`/api/users/${user.id}`, user).then(r => r.data),
onSuccess: (updated) => {
queryClient.setQueryData(['users', updated.id], updated);
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
TanStack Query in 2026 eliminates 80% of the boilerplate in typical React data fetching code. Start with useQuery for reads and useMutation for writes. Use invalidateQueries for simple cache updates and setQueryData for optimistic updates. The learning investment pays dividends on every project.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment