Fetching data is one of the most common — and error-prone — tasks in frontend development. Loading states, error boundaries, retries, stale data, race conditions… the list goes on.
Fortunately, TanStack Query (formerly React Query) gives you a powerful set of tools to tame this chaos. I’ll share a few patterns I’ve found especially helpful on real-world projects:
- Reusing query configuration across components
- Using
select
to shape responses - Writing mutations with optimistic updates
- Invalidating data to keep things in sync
Reusing Query Options for Clean, Cached Code
TanStack Query encourages collocation of data logic with components — but that doesn’t mean you have to repeat yourself. You can bundle the queryKey
, queryFn
, and shared options like staleTime
into a reusable object.
Let’s say we’re working with user profile data. Here’s how to set up a shared query definition:
// types.ts
export type Profile = {
id: string;
name: string;
email: string;
};
// api/profile.ts
import axios from 'axios';
import { Profile } from './types';
export const profileQueryOptions = {
queryKey: ['profile'],
queryFn: async (): Promise<Profile> => {
const { data } = await axios.get('/api/profile');
return data;
},
staleTime: 1000 * 60 * 5,
};
Then, in your hook:
import { useQuery } from '@tanstack/react-query';
import { profileQueryOptions } from './api/profile';
export const useProfile = () => {
return useQuery(profileQueryOptions);
};
Why This Pattern Is Great
- Single source of truth for query key, fetch logic, and behavior
- Reuses the same cache entry — no extra network requests
- Easy to use in prefetching or background updates
Using the Same Options Elsewhere
You can reuse the same query options and corresponding cache anywhere useQueryClient()
is available — such as prefetching data when a component mounts, in anticipation of a user visiting a related page.
Here’s an example where we prefetch a user’s profile as soon as a user card appears, ensuring the data is ready by the time the user navigates:
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { profileQueryOptions } from './api/profile';
const UserCard = () => {
const queryClient = useQueryClient();
useEffect(() => {
queryClient.prefetchQuery(profileQueryOptions);
}, [queryClient]);
return (
<a href="/profile">
View Profile
</a>
);
};
Because the queryKey
and queryFn
match your main useQuery hook, the cache is reused — so when the user lands on the profile page, there’s no need to refetch.
Picking Just What You Need with select
Sometimes your components only care about a slice of the data — not the whole response. TanStack Query’s select
option lets you project the data into exactly what you need.
Here’s how to reuse your query options while customizing the result:
const { data: profileName } = useQuery({
...profileQueryOptions,
select: (profile) => profile.name,
});
Another example: extracting an array of user IDs:
type User = {
id: string;
name: string;
};
const { data: userIds } = useQuery({
queryKey: ['users'],
queryFn: async (): Promise<User[]> => {
const { data } = await axios.get('/api/users');
return data;
},
select: (users) => users.map(u => u.id),
staleTime: 1000 * 60,
});
Mutations with Optimistic Updates
Mutations let you update data on the server — but with optimistic updates, you can reflect those changes immediately in the UI, even before the server confirms.
Here’s a mutation pattern for updating a post title, with optimistic caching:
type Post = {
id: number;
title: string;
};
export const useUpdatePostTitle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, newTitle }: { id: number; newTitle: string }) =>
axios.patch(`/api/posts/${id}`, { title: newTitle }),
onMutate: async ({ id, newTitle }) => {
await queryClient.cancelQueries(['posts']);
const previousPosts = queryClient.getQueryData<Post[]>(['posts']);
queryClient.setQueryData<Post[]>(['posts'], (old = []) =>
old.map(post =>
post.id === id ? { ...post, title: newTitle } : post
)
);
return { previousPosts };
},
onError: (_err, _variables, context) => {
queryClient.setQueryData(['posts'], context?.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries(['posts']);
},
});
};
When the mutation occurs the cache is updated with the same data that was sent to the server. If an error occurs during this mutation the change is rolled back using the previous data from the cache. Finally when the mutation settles, which happens on error or success, the cache is invalidated to ensure that the newest data from the server is populated into the cache.
Example Usage with Input
import { useState } from 'react';
import { useUpdatePostTitle } from './hooks/useUpdatePostTitle';
const PostEditor = ({ postId }: { postId: number }) => {
const [title, setTitle] = useState('');
const { mutate: updateTitle, isLoading } = useUpdatePostTitle();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
updateTitle({ id: postId, newTitle: title });
setTitle('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter new title"
disabled={isLoading}
/>
<button type="submit" disabled={isLoading || !title.trim()}>
{isLoading ? 'Updating...' : 'Update Title'}
</button>
</form>
);
};
Invalidate and Refetch After Mutations
After creating or deleting data, it’s best to invalidate affected queries so your UI stays in sync.
type NewPost = {
title: string;
content: string;
};
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newPost: NewPost) =>
axios.post('/api/posts', newPost),
onSuccess: () => {
queryClient.invalidateQueries(['posts']);
},
});
};
This ensures that your list view reflects the new post without manually appending it to cached data.
Wrapping Up
TanStack Query is more than a data-fetching library — it’s a powerful toolkit for managing server state and synchronizing your UI with confidence.
Use these:
- Reusable query definitions with
queryKey
,queryFn
, andstaleTime
select
for shaping return values cleanly- Optimistic mutations for better UX
- Cache invalidation to keep things in sync
You’ll drastically simplify complex frontend data flows.