A user taps a bookmark icon. Waiting for the server before changing its appearance makes a simple interaction feel slow, especially on an unreliable mobile connection.
Optimistic updates solve this by displaying the expected result immediately while the request runs in the background.
However, responsiveness is only half the problem. A reliable implementation must also handle rejected requests, repeated taps, offline sessions, and responses arriving in the wrong order.
The key is to separate the user’s intended change from the state the server has confirmed.
What Are Optimistic Updates?
An optimistic update changes the interface before the server confirms an operation.
For example, a React Native app immediately marks an article as bookmarked, then sends the update to its API.
The application must distinguish three outcomes:
| Outcome | Appropriate response |
|---|---|
| Server confirms success | Reconcile with the returned data |
| Server rejects the operation | Remove the optimistic change and explain the failure |
| Request outcome remains unknown | Show uncertainty and check authoritative state |
The distinction matters because a lost response does not necessarily mean a failed operation. The server may have saved the change before the connection dropped.
A local rollback restores the interface. It does not reverse an action already committed on the server.
When Should Apps Use Optimistic UI?
Optimistic behaviour suits predictable, reversible interactions such as bookmarks, reactions, and simple preferences.
Other actions need different treatment:
- Messages: Keep the content visible with a sending or failed status.
- Forms: Preserve entered information when submission fails.
- Purchases: Show processing until the server confirms completion.
- Destructive actions: Define confirmation and recovery behaviour explicitly.
The decision depends on the consequences of displaying an incorrect result.
An interface can respond immediately without claiming that an operation has succeeded.
Choose Between UI State and Cache Updates
TanStack Query supports two approaches: displaying pending mutation variables in the UI or updating the query cache through onMutate.
UI-level optimism works well when one component needs the temporary change. Confirmed data stays untouched while pending variables control the appearance.
Cache-level optimism helps when multiple components need to reflect the change immediately. However, it requires explicit rollback handling.
A useful model is:
Displayed state = confirmed data + pending local changes
This separation becomes particularly valuable when requests overlap.
Implement an Optimistic Bookmark in React Native
The following TypeScript example uses TanStack Query v5. It assumes a parent query supplies the current article and the component sits inside QueryClientProvider.
The supplied API function must return the canonical article or throw an error. Authentication and response validation belong in that API layer.
import { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
type Article = {
id: string;
bookmarked: boolean;
};
type Props = {
article: Article;
saveBookmark: (
id: string,
bookmarked: boolean,
) => Promise<Article>;
};
export function BookmarkButton({
article,
saveBookmark,
}: Props) {
const client = useQueryClient();
const locked = useRef(false);
const key = ['article', article.id] as const;
const mutation = useMutation<Article, Error, boolean>({
mutationKey: ['bookmark', article.id],
mutationFn: (value) =>
saveBookmark(article.id, value),
retry: false,
onSuccess: (saved) => {
client.setQueryData(key, saved);
},
onSettled: () =>
client.invalidateQueries({
queryKey: key,
exact: true,
}),
});
const bookmarked =
mutation.isPending && mutation.variables !== undefined
? mutation.variables
: article.bookmarked;
async function handlePress() {
if (locked.current || mutation.isPending) return;
locked.current = true;
try {
await mutation.mutateAsync(!bookmarked);
} catch {
// Error feedback is rendered below.
} finally {
locked.current = false;
}
}
return (
<View>
<Pressable
onPress={handlePress}
disabled={mutation.isPending}
accessibilityRole="button"
accessibilityLabel={
bookmarked ? 'Remove bookmark' : 'Bookmark article'
}
accessibilityState={{
disabled: mutation.isPending,
busy: mutation.isPending,
}}
>
<Text>{bookmarked ? '★ Saved' : '☆ Save'}</Text>
</Pressable>
{mutation.isPending && (
<Text>
{mutation.isPaused
? 'Waiting for connection…'
: 'Saving…'}
</Text>
)}
{mutation.isError && (
<Text>
Save could not be confirmed. Refresh before retrying.
</Text>
)}
</View>
);
}Pending variables update the icon immediately. Success replaces the cached detail with the server response. Failure removes the temporary overlay, revealing the latest confirmed value.
The ref prevents repeated presses before the disabled state renders. This example assumes one writer for the article; it is not a global concurrency solution.
Returning the invalidation promise keeps the mutation pending during reconciliation. Other caches, such as article lists, need their own updates or invalidation.
Handle Cache Rollbacks Without Losing Other Changes
A typical cache-based implementation cancels relevant queries, snapshots existing data, applies an optimistic change, and restores the snapshot on failure.
TanStack Query lets onMutate return rollback context for later callbacks.
However, restoring an entire snapshot can overwrite unrelated work.
Imagine two task edits:
- Task A updates optimistically.
- Task B updates successfully.
- Task A fails.
- Restoring the original list removes Task B’s successful change locally.
Prefer narrowly scoped rollbacks. For frequent overlapping edits, maintain pending operations separately and rebuild the visible state from confirmed data.
Removing one rejected operation should not remove another operation’s result.
Prevent Race Conditions and Duplicate Requests
Responses may arrive in a different order from requests. An older response can overwrite newer user intent unless the application accounts for ordering.
Useful strategies include:
- Allowing one outstanding write per entity.
- Queuing changes to the same record.
- Combining rapid interactions into the latest desired value.
- Tracking operation IDs and server revisions.
TanStack Query mutation scopes allow mutations sharing a scope.id to execute serially. This controls execution order, but does not automatically make optimistic cache logic conflict-safe.
For cross-device conflicts, server support is necessary. HTTP’s If-Match header can condition an update on a matching ETag, helping prevent lost updates. A failed precondition can return 412 Precondition Failed.
Prefer an explicit request such as bookmarked: true over “toggle bookmark.” Repeating the desired state is easier to make idempotent than repeating a toggle.
For creation requests, reuse a stable idempotency key when the backend supports deduplication. Simply adding a header does not implement that behaviour.
Treat Offline Mutations as Persistent Work
An optimistic screen is not an offline queue.
If the operating system terminates the app, pending operations stored only in memory may disappear.
Durable offline support needs persisted operations containing identifiers, account ownership, payloads, status, and any revision information required for reconciliation.
TanStack Query supports persisting and resuming paused mutations. Restored mutations require a registered default mutation function because functions cannot be serialized with mutation state. Failed mutations also do not retry by default.
For React Native, connect network status to TanStack’s onlineManager and app activity to focus management. Its React Native guide describes these integrations, while AppState provides foreground and background events.
Connectivity does not prove API availability. Reconcile uncertain operations after reconnecting, and never replay one account’s queued changes under another account.
Test Failures Before Shipping
Testing should cover sequences, not just individual successful requests.
| Scenario | Expected behaviour |
|---|---|
| Server rejects a mutation | Optimistic change is removed; useful input remains |
| Response disappears after a commit | App checks server state before assuming failure |
| Responses arrive out of order | Newer intent remains protected |
| One concurrent edit fails | Other successful edits remain intact |
| App restarts offline | Persisted work restores correctly |
| User changes accounts | Queued operations remain isolated |
| Refetch overlaps a mutation | Pending state is not accidentally overwritten |
React’s useOptimistic can simplify temporary rendering during an Action, but it does not provide persistent queues or server conflict resolution. Those remain application responsibilities.
Reliable optimistic UI combines immediate feedback with honest status reporting. Start with reversible interactions, distinguish rejection from uncertainty, and make reconciliation part of the design.





















Add Comment