TanStack Query (React Query): Handling HTTP Requests with EaseEntry 8 of 10
Optimistic Updates
Updating the UI before the server responds, assuming success, then rolling back if the request fails.
Notes
The flow uses four mutation callbacks and four queryClient methods:
onMutate(variables)runs as soon asmutate()is called, in parallel with the request. It receives the same object passed tomutate().queryClient.cancelQueries({ queryKey })stops in-flight fetches for that key so an older response can't overwrite the optimistic data.queryClient.getQueryData(queryKey)captures the current cached value for rollback.queryClient.setQueryData(queryKey, newData)writes the expected result into the cache. Every component using that key re-renders instantly.- Return
{ previous }fromonMutate. This becomes the context passed to later callbacks. onError(error, variables, context): restorecontext.previouswithsetQueryData.onSettled(): runs after success or failure. Invalidate the key here so the cache matches the server either way.
Should invalidateQueries go in onSettled? For optimistic updates, yes. After success it confirms the server's version (which may differ, e.g. server-generated fields); after failure it double-checks the rollback. For non-optimistic mutations, onSuccess is usually enough.
What / Why / When
- What
Instant UI feedback with rollback.
- Why
The app feels immediate instead of waiting on the network.
- When
Updates that almost always succeed: edits, likes, toggles, reordering.
Code compare
// pages/EditEvent.tsximport { useMutation, useQuery } from "@tanstack/react-query";import { fetchEvent, updateEvent, type EventInput, type EventItem } from "../services/events";import { queryClient } from "../util/query-client";export default function EditEvent() { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const { data, isPending, isError, error } = useQuery({ queryKey: ["events", id], queryFn: ({ signal }) => fetchEvent({ id: id!, signal }), }); const { mutate } = useMutation({ mutationFn: updateEvent, onMutate: async (variables) => { const key = ["events", variables.id]; await queryClient.cancelQueries({ queryKey: key }); // 1. stop in-flight fetches const previousEvent = queryClient.getQueryData<EventItem>(key); // 2. snapshot queryClient.setQueryData<EventItem>(key, (old) => // 3. optimistic write old ? { ...old, ...variables.event } : old, ); return { previousEvent }; // 4. context }, onError: (_error, variables, context) => { queryClient.setQueryData(["events", variables.id], context?.previousEvent); // rollback }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: ["events", variables.id] }); // resync }, }); function handleSubmit(formData: EventInput) { mutate({ id: id!, event: formData }); navigate("../"); // leave immediately; the detail page already shows the new data } if (isPending) return <LoadingIndicator />; if (isError) return <ErrorBlock title="Failed to load event" message={error.message} />; return ( <Modal onClose={() => navigate("../")}> <EventForm inputData={data} onSubmit={handleSubmit}> <Link to="../" className="button-text"> Cancel </Link> <button type="submit" className="button"> Update </button> </EventForm> </Modal> );}A resource's value is a writable signal, so it can be set optimistically and restored on error. For data shared across components, apply the same steps to a signal in a root service.
// event-details.component.tsexport class EventDetailsComponent { private eventsService = inject(EventsService); id = input.required<string>(); protected event = rxResource({ params: () => this.id(), stream: ({ params: id }) => this.eventsService.getEvent(id), }); update(changes: EventInput) { const previous = this.event.value(); // snapshot if (!previous) return; this.event.value.set({ ...previous, ...changes }); // optimistic write this.eventsService .updateEvent(this.id(), changes) .pipe(finalize(() => this.event.reload())) // like onSettled: resync .subscribe({ error: () => this.event.value.set(previous), // like onError: rollback }); }}// Shared optimistic state in a root service@Injectable({ providedIn: "root" })export class EventsStore { private eventsService = inject(EventsService); private _events = signal<EventItem[]>([]); readonly events = this._events.asReadonly(); updateEvent(id: string, changes: EventInput) { const previous = this._events(); this._events.update((list) => list.map((e) => (e.id === id ? { ...e, ...changes } : e))); this.eventsService.updateEvent(id, changes).subscribe({ error: () => this._events.set(previous), }); }}Differences and gotchas
cancelQuerieshas no direct Angular equivalent. Callingreload()or changingparamscancels the resource's previous request automatically.- TanStack Query passes the snapshot through
context; in Angular, a local variable captured in the closure does the same job. - Always snapshot before writing. Snapshotting after
setQueryDatacaptures the optimistic data, making rollback a no-op.
Quick reference
| Step | React | Angular |
|---|---|---|
| Stop stale fetches | cancelQueries({ queryKey }) | Automatic on reload() / param change |
| Snapshot | getQueryData(key) in onMutate | const previous = resource.value() |
| Optimistic write | setQueryData(key, newData) | resource.value.set(newData) |
| Pass snapshot | Return { previous } → context | Closure variable |
| Rollback | onError → setQueryData(key, context.previous) | error → value.set(previous) |
| Resync | onSettled → invalidateQueries | finalize → reload() |