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:

  1. onMutate(variables) runs as soon as mutate() is called, in parallel with the request. It receives the same object passed to mutate().
  2. queryClient.cancelQueries({ queryKey }) stops in-flight fetches for that key so an older response can't overwrite the optimistic data.
  3. queryClient.getQueryData(queryKey) captures the current cached value for rollback.
  4. queryClient.setQueryData(queryKey, newData) writes the expected result into the cache. Every component using that key re-renders instantly.
  5. Return { previous } from onMutate. This becomes the context passed to later callbacks.
  6. onError(error, variables, context): restore context.previous with setQueryData.
  7. 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

React
// 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.

Angular
// 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      });  }}
Angular
// 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

  • cancelQueries has no direct Angular equivalent. Calling reload() or changing params cancels 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 setQueryData captures the optimistic data, making rollback a no-op.

Quick reference

StepReactAngular
Stop stale fetchescancelQueries({ queryKey })Automatic on reload() / param change
SnapshotgetQueryData(key) in onMutateconst previous = resource.value()
Optimistic writesetQueryData(key, newData)resource.value.set(newData)
Pass snapshotReturn { previous } → contextClosure variable
RollbackonError → setQueryData(key, context.previous)error → value.set(previous)
ResynconSettled → invalidateQueriesfinalize → reload()