React Query Advanced: Optimistic Updates and Deduplication
Advanced TanStack Query patterns separate polished apps from rough ones. Request deduplication prevents duplicate HTTP calls when multiple components fetch the same data simultaneously. Optimistic updates make UIs feel instant by assuming mutations succeed before the server responds. Prefetching loads data before users need it, preventing loading skeletons. Background synchronization (keep-alive patterns) ensures stale data refreshes without user action. This article covers these production patterns that make your app feel responsive, correct, and efficient.
I once built a complex dashboard with 40+ query calls across many components. Without request deduplication, clicking a nav link triggered 8 identical requests simultaneously. Enabling deduplication (which is automatic in TanStack Query) cut network traffic by 85%. The same patterns that scale are the ones that polish the user experience for small apps too.
Request Deduplication
TanStack Query automatically deduplicates concurrent requests with the same query key. If two components call useQuery with the same key within a short window, only one HTTP request fires:
// Component A
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
return <div>{user?.name}</div>;
}
// Component B (renders at the same time)
function UserCard({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
return <p>{user?.name}</p>;
}
// In parent component, both render together
function App() {
return (
<div>
<UserProfile userId={1} />
<UserCard userId={1} />
</div>
);
}
// Result: Only one /api/users/1 request fires, both components share the cached result
Deduplication happens across a configurable time window (default: 0ms, meaning all renders in the same React batch are deduplicated). You do not need to configure anything; it is automatic.
Optimistic Updates with Rollback
Optimistic updates assume mutations succeed and immediately update the UI. If the mutation fails, the old data is restored:
function useUpdateUserName() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ userId, newName }) => {
const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
});
if (!res.ok) throw new Error('Failed to update');
return res.json();
},
onMutate: async ({ userId, newName }) => {
// Cancel any pending refetch
await queryClient.cancelQueries({
queryKey: ['user', userId],
});
// Save old data
const previousUser = queryClient.getQueryData(['user', userId]);
// Update UI optimistically
queryClient.setQueryData(['user', userId], (old) => ({
...old,
name: newName,
}));
// Return old data for rollback
return { previousUser };
},
onError: (error, variables, context) => {
// Restore old data on error
if (context?.previousUser) {
queryClient.setQueryData(['user', variables.userId], context.previousUser);
}
},
onSuccess: ({ userId }) => {
// Optionally refetch to sync with server
queryClient.invalidateQueries({
queryKey: ['user', userId],
exact: true,
});
},
});
}
function EditUserForm({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
const { mutate: updateName, isPending } = useUpdateUserName();
const handleChange = (newName) => {
updateName({ userId, newName }); // UI updates instantly
};
return (
<input
value={user?.name}
onChange={(e) => handleChange(e.target.value)}
disabled={isPending}
/>
);
}
The user sees their name change instantly. If the update succeeds, the UI stays as-is. If it fails, the old name is restored, and the user is notified of the error.
Prefetching Data for Instant Navigation
Prefetch data before the user navigates to a page, eliminating loading skeletons:
function useUserListPreload() {
const queryClient = useQueryClient();
return (userId) => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 1000 * 60, // Data is fresh for 1 minute
});
};
}
function UserListLink({ userId }) {
const preloadUser = useUserListPreload();
return (
<Link
to={`/users/${userId}`}
onMouseEnter={() => preloadUser(userId)} // Prefetch on hover
onTouchStart={() => preloadUser(userId)} // Prefetch on touch
>
View User
</Link>
);
}
function UserDetail({ userId }) {
// Data is already cached from prefetch, so no loading state
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
if (!user) return <Skeleton />; // Rarely shown; data prefetched
return <div>{user.name}</div>;
}
When the user hovers over a link, the data is prefetched. By the time they click, the data is usually in cache.
Batch Prefetching
Prefetch multiple queries at once when a page loads:
function usePrefetchDashboard(userId) {
const queryClient = useQueryClient();
React.useEffect(() => {
// Prefetch all dashboard data
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
queryClient.prefetchQuery({
queryKey: ['userStats', userId],
queryFn: () => fetch(`/api/users/${userId}/stats`).then(r => r.json()),
});
queryClient.prefetchQuery({
queryKey: ['userPosts', userId],
queryFn: () => fetch(`/api/users/${userId}/posts`).then(r => r.json()),
});
}, [userId, queryClient]);
}
function Dashboard({ userId }) {
usePrefetchDashboard(userId);
// All data is likely cached
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
const { data: stats } = useQuery({
queryKey: ['userStats', userId],
queryFn: () => fetch(`/api/users/${userId}/stats`).then(r => r.json()),
});
// No loading skeletons; data is ready
return <div>{user?.name} — {stats?.views} views</div>;
}
Deduplication Window Configuration
Control the deduplication window on the QueryClient:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
dedupeInterval: 0, // Deduplicate queries fired in the same render cycle
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 5,
},
},
});
The dedupeInterval (in milliseconds) sets how long identical queries are coalesced. 0 deduplicates within the same render batch; 1000 deduplicates across 1 second.
Background Synchronization with Keep-Alive Queries
Keep queries fresh in the background even when the user is not actively viewing them:
function useKeepNotificationsAlive() {
useQuery({
queryKey: ['notifications'],
queryFn: () => fetch('/api/notifications').then(r => r.json()),
refetchInterval: 1000 * 30, // Poll every 30 seconds
refetchIntervalInBackground: true, // Continue polling even if tab is hidden
});
}
function App() {
// Start background sync for notifications
useKeepNotificationsAlive();
return (
<div>
<NotificationBell />
<MainApp />
</div>
);
}
function NotificationBell() {
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: () => fetch('/api/notifications').then(r => r.json()),
});
const unreadCount = notifications?.filter(n => !n.read).length || 0;
return (
<div>
Notifications <span>{unreadCount}</span>
</div>
);
}
The useKeepNotificationsAlive hook polls every 30 seconds (in background too). The NotificationBell component uses the same query key, so it automatically receives updates.
Cache Invalidation Strategies
Selective invalidation prevents unnecessary refetches:
function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (postId) => {
const res = await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
return res.json();
},
onSuccess: ({ postId }) => {
// Invalidate only the affected queries
queryClient.invalidateQueries({
queryKey: ['post', postId], // Only this post
exact: true,
});
queryClient.invalidateQueries({
queryKey: ['posts'], // All post lists
exact: false,
});
// Do NOT invalidate unrelated queries
// queryClient.invalidateQueries({ queryKey: ['users'] }); // Not needed
},
});
}
Invalidate only the queries affected by the mutation. Invalidating all queries is wasteful.
Prefetch on Scroll
For infinite lists, prefetch the next page before the user scrolls to the bottom:
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
});
const queryClient = useQueryClient();
const handleScroll = React.useCallback(
(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// Prefetch next page if scrolled 80% down
if (scrollTop + clientHeight > scrollHeight * 0.8 && hasNextPage) {
const nextPage = data?.pages.length ? data.pages.length + 1 : 2;
queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
pages: [nextPage], // Prefetch only the next page
});
}
},
[hasNextPage, data, queryClient]
);
return (
<div onScroll={handleScroll} style={{ overflowY: 'auto', height: '100vh' }}>
{data?.pages.map((page) =>
page.items.map(post => <PostCard key={post.id} post={post} />)
)}
{isFetchingNextPage && <p>Loading more...</p>}
</div>
);
}
When the user scrolls 80% down, the next page is prefetched. By the time they scroll to the bottom, the data is ready.
Real-Time Sync with Mutations and Subscriptions
Combine mutations and real-time updates for truly responsive apps:
function usePostSubscription(postId) {
const queryClient = useQueryClient();
React.useEffect(() => {
// Subscribe to real-time updates (e.g., WebSocket)
const unsubscribe = subscribeToPost(postId, (updatedPost) => {
queryClient.setQueryData(['post', postId], updatedPost);
});
return unsubscribe;
}, [postId, queryClient]);
}
function useUpdatePostOptimistic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ postId, updates }) => {
const res = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
return res.json();
},
onMutate: async ({ postId, updates }) => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previousPost = queryClient.getQueryData(['post', postId]);
queryClient.setQueryData(['post', postId], (old) => ({
...old,
...updates,
}));
return { previousPost };
},
onError: (error, variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['post', variables.postId], context.previousPost);
}
},
});
}
function PostEditor({ postId }) {
usePostSubscription(postId); // Real-time sync
const { data: post } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()),
});
const { mutate: updatePost, isPending } = useUpdatePostOptimistic();
return (
<input
value={post?.title}
onChange={(e) => updatePost({ postId, updates: { title: e.target.value } })}
disabled={isPending}
/>
);
}
Combining optimistic updates with real-time subscriptions creates a seamless collaborative editing experience.
Key Takeaways
- Request deduplication is automatic: multiple queries with the same key fire only one HTTP request.
- Optimistic updates assume mutations succeed and rollback on error, creating instant UIs.
- Prefetch data before users navigate to it using
queryClient.prefetchQuery(). - Control refetch behavior with
refetchIntervalandrefetchIntervalInBackgroundfor background synchronization. - Invalidate only affected queries after mutations; broad invalidation is wasteful.
- Combine prefetching, mutations, and real-time subscriptions for production-grade responsiveness.
Frequently Asked Questions
Does deduplication work across different query key structures?
No. Deduplication only works for identical query keys. ['user', 1] and ['user', 1, 'posts'] are different keys and generate separate requests.
Can I manually control when a query is deduplicated?
Not directly, but you can set dedupeInterval on the QueryClient to control the deduplication window. For zero deduplication, set dedupeInterval: 0 and render each useQuery in separate React render cycles (unlikely in practice).
Is prefetching the same as caching?
Prefetching writes to the cache before the user accesses the data. Caching stores data after access and reuses it on subsequent accesses. Prefetching is proactive; caching is reactive.
What is the cost of over-prefetching?
Extra HTTP requests and memory usage. Prefetch only data the user is likely to access soon (hover links, adjacent pages). Prefetching hundreds of items is wasteful.
Can I prefetch an infinite query?
Yes, use queryClient.prefetchInfiniteQuery(), passing the pages option to prefetch a specific page.