Practical TanStack Query v5: Optimistic Updates and Offline Caching Patterns

In modern web applications, User Experience (UX) is the core factor that determines the success of an application. Imagine if the UI reacted instantly instead of showing a loading spinner when a user clicks a button.
In this article, we will take a deep dive into implementing Optimistic Updates to provide a zero-latency experience using TanStack Query v5 (formerly React Query), and explore Offline Caching Patterns in case of network disconnects.
1. What are Optimistic Updates?
Optimistic updates are a technique where the UI is updated immediately, optimistically assuming the request will succeed, without waiting for the server’s response.
- Pros: Users don’t notice network latency and perceive the app as extremely fast.
- Cons: Adds complexity in rolling back the UI to its previous state if the server request fails.
In TanStack Query v5, you can handle this rollback mechanism elegantly through the lifecycle callbacks of the useMutation hook (onMutate, onError, onSettled).
[!NOTE] In v5, the context object returned from
onMutateis passed toonErrorandonSettledand used for rollbacks.
2. Implementing Optimistic Updates with TanStack Query v5
Let’s look at a simple ‘Like’ toggle feature as an example.
import { useMutation, useQueryClient } from '@tanstack/react-query';
// Post type definition
interface Post {
id: string;
title: string;
likes: number;
}
export function useToggleLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
// onMutate: Called before the mutation function is executed
onMutate: async (newPostId) => {
// 1. Cancel any outgoing refetches to avoid overwriting our optimistic update
await queryClient.cancelQueries({ queryKey: ['posts', newPostId] });
// 2. Snapshot the previous value for recovery on error
const previousPost = queryClient.getQueryData<Post>(['posts', newPostId]);
// 3. Optimistically update the cache
if (previousPost) {
queryClient.setQueryData<Post>(['posts', newPostId], {
...previousPost,
likes: previousPost.likes + 1,
});
}
// 4. Return a context with the previous data
return { previousPost };
},
// onError: Rollback to the previous state using the context returned from onMutate
onError: (err, newPostId, context) => {
if (context?.previousPost) {
queryClient.setQueryData(['posts', newPostId], context.previousPost);
}
},
// onSettled: Invalidate queries to ensure fresh data regardless of success or failure
onSettled: (newPostId) => {
queryClient.invalidateQueries({ queryKey: ['posts', newPostId] });
},
});
}
Key Points Analysis
cancelQueries: Prevents optimistic updates from being overwritten by stale data arriving from a delayed fetch.previousPostSnapshot: Saves the original state before modifying the cache.- Context Return: The value returned by
onMutateis passed as the third argument toonErrorfor use in rollbacks.
3. Offline-First and Caching Patterns
To prevent users from seeing a blank screen when offline or on an unstable network, a proper caching strategy is required. TanStack Query v5 provides robust offline mechanisms by default.
3.1 Configuring Network Mode
TanStack Query’s default networkMode is online. This means that if there is no network connection, queries and mutations will pause and wait.
online(default): Pause requests until the network is reconnected.always: Always try to fetch and update cache, ignoring network state.offlineFirst: Prioritize using the offline cache while performing background syncing.
If you are developing an offline-first app, it’s recommended to use the offlineFirst setting to show cached data while attempting to sync upon reconnection.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
3.2 Persistent Caching with Persisters
To retain data even after refreshing or closing and reopening the browser, you need a Persister plugin. In v5, asynchronous persisters based on @tanstack/query-sync-storage-persister or idb-keyval (IndexedDB) are commonly used.
Example of cache persistence using IndexedDB:
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 60 * 24, // Keep cache for 24 hours
},
},
});
const indexedDBPersister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
});
// Wrap with Provider at the top of your App
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister: indexedDBPersister }}
>
<YourApp />
</PersistQueryClientProvider>
);
}
4. Practical Edge Cases and Architectural Decisions
When applying optimistic updates and offline caching in production, there are a few edge cases to watch out for.
4.1 Rapid Consecutive Optimistic Updates
Think of a scenario where a user rapidly clicks the ‘like’ button multiple times. If onMutate fires every time, the snapshot might capture an intermediate (already mutated) state.
Solutions:
- Apply debouncing on the UI side.
- Manage an update queue locally before mutating the cache.
- For array data (e.g., adding a Todo), issue a temporary ID (
uuid) and replace it with the real ID after the server responds.
| Issue | Cause | Solution Pattern |
|---|---|---|
| Rollback failure | Missing cache snapshot before onMutate |
Always return the queryClient.getQueryData snapshot as context |
| Data overwrite | In-flight GET arrives after POST | Place queryClient.cancelQueries at the top of onMutate |
| List UI flicker | Mismatch between temp ID and server ID | Replace the temp object with the server response object using map |
Summary
- Optimistic updates drastically improve UX by giving users a sense of immediate responsiveness.
- Consistently maintain the pattern of saving a snapshot in
onMutateand rolling back inonError. - Build robust offline web apps by combining the
offlineFirstnetwork mode with IndexedDB-based Persisters. - Design for edge cases like consecutive events or temporary ID management upfront to maintain data integrity.