TanStack Query Mutations: Optimistic Updates and Cache Invalidation in Detail
AI generated
{ }
React 19 · TanStack Query · Server State
Mastering TanStack Query Mutations
Optimistic updates, rollback, and targeted cache invalidation instead of blanket refetching

A POST request is quick to fire off, the real work starts afterwards: updating the cache, invalidating affected queries, cleanly rolling back on failure. useMutation offers a clear API pattern for this instead of scattered handler logic.

14 min read useMutation · TanStack Query v5 Cache Invalidation

1. useQuery Reads, useMutation Writes

useQuery is responsible for reading server state and keeps the results in a central cache that multiple components can subscribe to at once. useMutation handles the complementary case, changing that state through POST, PUT, PATCH, or DELETE requests, and is the intended place to trigger side effects such as cache updates after a successful change.

Unlike a manual approach with fetch and local useState, useMutation encapsulates status information such as isPending, isError, and isSuccess, built-in retry logic, and a fixed sequence of callback hooks that mesh seamlessly with the same query cache useQuery uses.

2. The Basics of useMutation

useMutation({ mutationFn, onMutate, onError, onSuccess, onSettled }) takes the actual request function plus a set of optional callbacks. mutate() fires the mutation fire-and-forget style, mutateAsync() additionally returns a promise in case the calling code needs to await the result directly. onMutate runs synchronously before the actual request and is the right place for optimistic updates.

onSettled always runs, regardless of whether the mutation succeeded or failed, which makes it a good central place for final invalidateQueries calls. That way the cache stays back in sync with the actual server state in both cases, while onSuccess and onError each only handle the matching outcome.


import { useMutation, useQueryClient } from '@tanstack/react-query';

function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo) => api.updateTodo(todo.id, todo),
    onSuccess: () => {
      console.log('Todo updated successfully');
    },
    onError: (error) => {
      console.error('Mutation failed', error);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

3. Implementing Optimistic Updates

With an optimistic update, the UI is updated immediately, before the server's response even arrives. In onMutate, that's done by writing directly to the cache via queryClient.setQueryData, with exactly the value the UI should show once the mutation succeeds.

Before the cache is written optimistically, queryClient.cancelQueries has to be called for the same query key. Skip that step, and a still-running background refetch can overwrite the optimistic update while the mutation is still in flight, causing a brief, confusing snap-back in the display.


function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo) => api.updateTodo(todo.id, todo),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
      const previous = queryClient.getQueryData(['todos', newTodo.id]);

      queryClient.setQueryData(['todos', newTodo.id], newTodo);

      return { previous };
    },
  });
}

4. Rolling Back Failed Mutations

onMutate can return a context value, usually a snapshot of the old cache value taken before the optimistic write. That context is automatically passed as the third argument to onError, letting it reset the cache with setQueryData back to exactly the state before the mutation.

Without this snapshot mechanism, a failed request would leave an optimistic state, never confirmed by the server, permanently visible in the UI. The combination of a snapshot in onMutate and a rollback in onError is therefore not optional polish but a necessary requirement as soon as optimistic updates are used at all.


function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (todo) => api.updateTodo(todo.id, todo),
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
      const previous = queryClient.getQueryData(['todos', newTodo.id]);
      queryClient.setQueryData(['todos', newTodo.id], newTodo);
      return { previous };
    },
    onError: (error, newTodo, context) => {
      // Roll back to the state before the optimistic change
      queryClient.setQueryData(['todos', newTodo.id], context.previous);
    },
  });
}

5. Targeted Cache Invalidation

invalidateQueries({ queryKey }) marks all matching queries as stale and automatically refetches actively subscribed ones. A granular query key structure is key to precise invalidation, for example ['todos', 'list', { filters }] instead of a blanket ['todos'] that matches everything under that prefix.

Overly broad invalidation triggers unnecessarily many simultaneous refetches, straining the network and the server without real benefit. Overly narrow invalidation, on the other hand, misses dependent queries, for example a detail view of a todo that was just deleted from the list, whose detail query is now stale too but stays untouched.


// Only matches list queries with the matching prefix,
// not the individual detail queries per todo id
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });

// Matches every query whose key starts with 'todos',
// including both list and detail queries
queryClient.invalidateQueries({ queryKey: ['todos'] });

// Predicate based invalidation for more complex cases
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' && query.queryKey[1] === 'list',
});

6. The Difference from Manual Refetching

A hand-rolled pattern where a fetch call in an event handler is followed by another fetch in a useEffect, or a full page reload, only ever synchronizes the one component that contains that code. Other components showing the same server state, say a counter in the navigation bar, stay stale until they reload on their own.

useMutation combined with invalidateQueries, by contrast, automatically synchronizes every component subscribed to the same query key, regardless of where in the component tree the mutation was triggered. That is the real structural benefit over hand-rolled fetch logic, not just less code, but consistent state across the entire application.

7. Ordering for Rapid Consecutive Mutations

Rapid consecutive mutations, say an accidental double click on a like button, can arrive at the server out of order depending on network conditions. mutationKey together with the networkMode and retry options helps keep retry behavior and network behavior consistent for a group of related mutations.

For strictly sequential mutations where order matters for correctness, a dedicated queue is worth building, for example an async function that processes mutations one after another via await mutateAsync(). For the simpler, but very common case, it's usually enough to disable the triggering button while isPending is true, preventing double submits in the first place.


function LikeButton({ postId }) {
  const { mutate, isPending } = useMutation({
    mutationFn: () => api.likePost(postId),
  });

  return (
    <button disabled={isPending} onClick={() => mutate()}>
      {isPending ? 'Sending...' : 'Like'}
    </button>
  );
}

8. Optimistic Updates for Lists

For lists like a todo list or comments, the optimistic update doesn't affect a single value but an entire array in the cache. A new item is inserted via setQueryData((old) => [...old, tempItem]), a deleted item removed from the existing array via filter accordingly, both inside onMutate.

For a newly inserted item, a client-generated temporary id, say via crypto.randomUUID(), is worth using while the real, server-assigned id isn't known yet. After a successful response, the temporary item is either replaced by the real server object in onSuccess, or the affected query key is fully reloaded via invalidateQueries.


function useAddComment(postId) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (text) => api.addComment(postId, text),
    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] });
      const previous = queryClient.getQueryData(['comments', postId]);
      const tempItem = { id: crypto.randomUUID(), text, pending: true };

      queryClient.setQueryData(['comments', postId], (old = []) => [...old, tempItem]);
      return { previous };
    },
    onError: (err, text, context) => {
      queryClient.setQueryData(['comments', postId], context.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['comments', postId] });
    },
  });
}

9. Common Mistakes with Mutations

The most common mistake is an optimistic update in onMutate without a matching rollback in onError. If the request fails, the optimistically written state, never confirmed by the server, stays permanently visible in the UI, which is especially confusing for deleted or changed entries.

A second common mistake is a wrong or overly specific query key in invalidateQueries, so related queries such as a detail page, a counter, or a filtered list don't get updated too. On top of that, missing cancelQueries before the optimistic write is common, letting a concurrent background refetch silently overwrite the optimistic update.

Strategy UI Response Time Error Handling When to Use
Manual refetch after mutation Delayed until server response plus another request Manual in the handler, often inconsistent Only for very simple prototypes
useMutation without optimistic update Delayed until server response Handled centrally via onError When UI latency is acceptable
useMutation with optimistic update and rollback Immediate, before the server response Automatic rollback via snapshot For frequent interactions like likes, toggles
useMutation with targeted invalidateQueries Immediately visible after successful mutation Consistent across all subscribed components For changes affecting multiple views

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

TanStack Query Mutations: The Essentials at a Glance

onMutate

Runs synchronously before the request, the place for optimistic setQueryData and a snapshot of the old value.

onError

Receives the snapshot from onMutate as context and uses it to roll back to the previous state.

onSettled

Always runs regardless of outcome, the right place for final invalidateQueries calls.

Query Keys

A granular query key structure enables precise invalidation instead of blanket, expensive refetching.

11. FAQ: TanStack Query Mutations: The Essentials at a Glance

1What is the difference between useQuery and useMutation?
useQuery reads server state and keeps it in the cache, useMutation changes that state through POST, PUT, PATCH, or DELETE requests. useMutation is the intended place to trigger side effects like cache updates after a successful change.
2When does onMutate run compared to onSuccess and onError?
onMutate runs synchronously before the actual request is sent, which makes it suitable for optimistic updates. onSuccess only runs on a successful response, onError only on a failed request, while onSettled runs in both cases.
3Why does cancelQueries have to be called before an optimistic update?
Without cancelQueries, a still-running background refetch for the same query key can overwrite the optimistic update that was just written, while the mutation is still in flight. That causes a brief, confusing snap-back in the display.
4How does rollback work for a failed optimistic mutation?
onMutate returns a context with a snapshot of the old cache value. That context is automatically passed as the third argument to onError, where setQueryData resets the cache back to exactly the state before the mutation.
5Why does a granular query key structure matter?
A granular structure like ['todos', 'list', filters] instead of a blanket ['todos'] enables precise invalidation. Overly broad invalidation triggers unnecessarily many refetches, overly narrow invalidation misses dependent queries such as a detail view.
6What is the advantage of useMutation over manual fetch plus useEffect?
useMutation with invalidateQueries automatically synchronizes every component subscribed to the same query key, regardless of where in the component tree the mutation was triggered. Manual fetch plus useEffect, by contrast, only synchronizes the one component that contains that code.
7How do I prevent double submits on rapid clicks?
The simplest way is to disable the triggering button while isPending is true. For strictly sequential mutations where order matters for correctness, a dedicated queue using await mutateAsync() is worth building.
8How do I handle optimistic updates for a list instead of a single value?
A new item is inserted via setQueryData with a function that extends the existing array with a new item, a deleted item removed accordingly via filter. For new items, a temporary client-side id is worth using until the real server id is known.
9What is the difference between mutate() and mutateAsync()?
mutate() fires the mutation fire-and-forget style and doesn't return a promise, errors are handled via onError. mutateAsync() additionally returns a promise that can be awaited directly in the calling code, useful for sequential flows.
10What happens if I forget onError for an optimistic update?
Without a rollback in onError, a failed request leaves the optimistically written state, never confirmed by the server, permanently visible in the UI. That leads to inconsistent views that only get corrected by a manual reload.