Skip to content
React rc data 4 min read

Mutations & Cache Updates

Reading data is only half of working with server state — eventually you need to change it. A mutation is any request that creates, updates, or deletes data on the server (typically POST, PUT, PATCH, or DELETE). The hard part is not sending the request; it is keeping your cached, already-rendered data in sync afterward so the UI reflects the new reality. TanStack Query’s useMutation hook handles the lifecycle of a write and gives you precise hooks for updating the cache, including optimistic updates with automatic rollback.

The useMutation hook

Unlike useQuery, which runs automatically, a mutation runs only when you call mutate. You give useMutation a mutationFn that performs the write and returns a promise; the hook returns that trigger plus status flags for building loading and error UI.

import { useMutation } from "@tanstack/react-query";

async function createPost(newPost) {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newPost),
  });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
}

function NewPostButton() {
  const { mutate, isPending, isError, error, isSuccess } = useMutation({
    mutationFn: createPost,
  });

  return (
    <div>
      <button
        disabled={isPending}
        onClick={() => mutate({ title: "Hello", body: "World", userId: 1 })}
      >
        {isPending ? "Saving…" : "Create post"}
      </button>
      {isError && <p role="alert">Error: {error.message}</p>}
      {isSuccess && <p>Post created!</p>}
    </div>
  );
}

Output:

Create post     (idle)
Saving…         (while the request is in flight)
Post created!   (on success)

The mutate function is fire-and-forget. If you need to await the result, use mutateAsync, which returns the promise so you can try/catch around it.

Invalidating vs. updating the cache

After a successful write, the data you previously fetched is stale. You have two ways to reconcile it.

StrategyHow it worksWhen to use
InvalidateMark matching queries stale; they refetch from the serverDefault choice — guarantees correctness, costs one round trip
Update directlyWrite the new value into the cache with setQueryDataAvoid an extra fetch when the server response already contains the final data

Invalidation is the simplest and safest. You let the server remain the source of truth:

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

function useCreatePost() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
}

If the response already contains the authoritative record, you can splice it straight into the cache and skip the refetch:

onSuccess: (created) => {
  queryClient.setQueryData(["posts"], (old = []) => [...old, created]);
},

Tip: When in doubt, invalidate. Direct cache writes are faster but force you to replicate server logic (sorting, derived fields, IDs) on the client — get it wrong and the UI silently drifts from the database.

Optimistic updates with rollback

For snappy UIs you can update the cache before the server responds, then roll back if it fails. useMutation exposes a lifecycle built exactly for this: onMutate runs first (apply the optimistic change and snapshot the old state), onError rolls back, and onSettled reconciles by refetching.

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

  return useMutation({
    mutationFn: (todo) =>
      fetch(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !todo.completed }),
      }).then((r) => {
        if (!r.ok) throw new Error("Update failed");
        return r.json();
      }),

    onMutate: async (todo) => {
      // 1. Cancel in-flight refetches so they can't overwrite our optimistic value
      await queryClient.cancelQueries({ queryKey: ["todos"] });
      // 2. Snapshot the current cache for rollback
      const previous = queryClient.getQueryData(["todos"]);
      // 3. Optimistically apply the change
      queryClient.setQueryData(["todos"], (old = []) =>
        old.map((t) =>
          t.id === todo.id ? { ...t, completed: !t.completed } : t
        )
      );
      // 4. Pass the snapshot to onError via context
      return { previous };
    },

    onError: (_err, _todo, context) => {
      // Restore the snapshot taken in onMutate
      queryClient.setQueryData(["todos"], context.previous);
    },

    onSettled: () => {
      // Always resync with the server, whether we succeeded or failed
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
}

The four steps matter in order: cancelling queries prevents a slow background fetch from clobbering your optimistic state, and the snapshot returned from onMutate becomes the context argument every other callback receives.

A worked example: a todo list

Putting it together, a single component can render the list, fire the mutation, and reflect the optimistic toggle instantly.

import { useQuery } from "@tanstack/react-query";

function TodoList() {
  const { data: todos, isPending } = useQuery({
    queryKey: ["todos"],
    queryFn: () =>
      fetch("https://jsonplaceholder.typicode.com/todos?_limit=5").then((r) =>
        r.json()
      ),
  });
  const toggle = useToggleTodo();

  if (isPending) return <p>Loading…</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <label>
            <input
              type="checkbox"
              checked={todo.completed}
              disabled={toggle.isPending}
              onChange={() => toggle.mutate(todo)}
            />
            {todo.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

Because onMutate updates the cache synchronously, the checkbox flips the instant it is clicked — no spinner, no waiting on the network. If the request fails, onError snaps it back.

Best Practices

  • Reach for invalidation by default; only do optimistic updates for high-frequency, low-risk interactions like toggles, likes, and reordering.
  • Always cancelQueries inside onMutate so a pending background refetch cannot overwrite your optimistic value.
  • Return a rollback snapshot from onMutate and restore it in onError — never assume a mutation succeeds.
  • Use onSettled to invalidate, guaranteeing the cache matches the server regardless of success or failure.
  • Drive button state from isPending, and disable triggers while a mutation is in flight to prevent duplicate submissions.
  • Prefer setQueryData over a refetch only when the server response is the authoritative final record.
  • Extract mutations into custom hooks (useCreatePost, useToggleTodo) to keep components declarative and reusable.
Last updated June 14, 2026
Was this helpful?