Week 9: Mutations, Optimistic Updates & Cancellation

Week 8 covered reading server data. This week covers writing it — creating, updating and deleting through the same cache, keeping the UI in sync afterward, and the technique that makes an app feel instant: updating the screen before the server has actually confirmed the change.

Module 6 of 17 Week 9 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Perform a write with useMutation and refresh affected queries
  • Implement an optimistic update with correct rollback on failure
  • Cancel an in-flight request that's no longer needed

1. useMutation

Where useQuery is for reads that run automatically, useMutation is for writes you trigger explicitly — creating a todo, updating a profile, deleting a comment. It gives you a mutate function and status flags, with no automatic execution on render.

AddTodoForm.tsx
import { useMutation } from '@tanstack/react-query';

function AddTodoForm() {
  const { mutate, isPending, isError } = useMutation({
    mutationFn: (text: string) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text }),
        headers: { 'Content-Type': 'application/json' },
      }).then(res => res.json()),
  });

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const text = new FormData(e.currentTarget).get('text') as string;
    mutate(text);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="text" />
      <button disabled={isPending}>{isPending ? 'Adding...' : 'Add'}</button>
      {isError && <p>Something went wrong.</p>}
    </form>
  );
}

mutationFn is the async function that performs the write. mutate(text) calls it with whatever argument you pass, and isPending/isError track its status — the same status-flag shape as useQuery, applied to a write instead of a read.

2. Invalidating Queries

A successful mutation on the server doesn't automatically update anything already cached from Week 8's queries — the todos list query still holds whatever it fetched before the new todo existed. Invalidation tells TanStack Query "this cached data might be wrong now, refetch it":

AddTodoForm.tsx — with invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodoForm() {
  const queryClient = useQueryClient(); // access to the shared cache

  const { mutate, isPending } = useMutation({
    mutationFn: (text: string) => createTodo(text),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] }); // refetch anything keyed ['todos', ...]
    },
  });

  // ...
}

invalidateQueries marks every query whose key matches (or starts with) ['todos'] as stale and triggers a background refetch for any that are currently in use — so the todos list re-renders with the new item shortly after the mutation succeeds, with zero manual cache-editing on your part.

3. Optimistic Updates

Invalidation still means waiting for a refetch after the mutation resolves — for a fast action like toggling a todo's checkbox, that round-trip delay is noticeable and feels sluggish. An optimistic update updates the cache immediately, assuming success, and rolls back only if the mutation actually fails.

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

  return useMutation({
    mutationFn: (id: number) => toggleTodoOnServer(id),

    onMutate: async (id: number) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] }); // avoid a race with an in-flight refetch

      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      queryClient.setQueryData<Todo[]>(['todos'], old =>
        old?.map(todo => (todo.id === id ? { ...todo, done: !todo.done } : todo))
      );

      return { previousTodos }; // saved for rollback in onError
    },

    onError: (_err, _id, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos); // roll back
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] }); // reconcile with the server either way
    },
  });
}

Four lifecycle callbacks work together here: onMutate runs before the network request, snapshotting the current cache and applying the optimistic change; onError restores that snapshot if the server rejects the mutation; onSettled runs after either outcome and invalidates to reconcile the cache with whatever the server actually ended up with. The checkbox flips instantly on click — the network request happens invisibly behind that instant feedback.

Not every mutation should be optimistic

Optimistic updates suit low-risk, easily-reversible actions (toggling, liking, reordering). For something consequential — placing an order, deleting an account — showing success before the server has actually confirmed it can mislead the user; a plain pending state with a real wait is often the more honest choice.

4. Loading/Error UI Patterns

A few small conventions make loading and error states feel considered rather than bolted on. First: distinguish the very first load of a query from a background refetch of already-cached data — isPending only reflects "no data yet at all," while isFetching is also true during any background refetch.

TodoList.tsx
function TodoList() {
  const { data: todos, isPending, isFetching, isError } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  if (isPending) return <Skeleton />;            // truly nothing to show yet
  if (isError) return <p>Couldn't load todos.</p>;

  return (
    <div>
      {isFetching && <span className="subtle-spinner" />} {/* refreshing existing data */}
      <ul>{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>
    </div>
  );
}

This distinction matters for feel: replacing the whole list with a full-page spinner on every background refetch (like the window-refocus refetch from Week 8) is jarring; a small, unobtrusive indicator while the existing data stays visible reads as "quietly keeping this fresh" instead of "reloading."

5. Request Cancellation

If a query's inputs change before the previous request finishes — typing quickly in a search box, navigating away from a page mid-fetch — the old request is wasted work. TanStack Query passes an AbortSignal into queryFn, wired to cancel automatically when a query becomes obsolete:

cancellable queryFn
useQuery({
  queryKey: ['search', query],
  queryFn: async ({ signal }) => {
    const response = await fetch(`/api/search?q=${query}`, { signal });
    return response.json();
  },
});

Passing signal straight to fetch is enough — if the query changes again before this request resolves (making this exact query key obsolete), TanStack Query aborts the in-flight request via that signal automatically. The browser stops the network request outright, rather than just ignoring its result the way Week 2's hand-written ignore flag did — genuinely canceling wasted work instead of merely discarding it after the fact.

6. useOptimistic: React's Own Optimistic-Update Primitive

Section 3's optimistic pattern lives entirely inside TanStack Query's cache — you manually snapshot the previous value in onMutate and manually restore it in onError. That's the right tool when the optimistic value needs to appear everywhere that query is read (a todo's checked state shown in both a list and a detail view). When the optimistic value is local to one piece of UI — or there's no TanStack cache involved at all, as with the Server Actions in Week 20 — React 19's built-in useOptimistic does the same revert-on-settle bookkeeping without the manual snapshot/rollback code.

LikeButton.tsx
import { useOptimistic, useState } from 'react';

function LikeButton({ postId, initialLikes }: { postId: number; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (current, amount: number) => current + amount,
  );

  async function handleLike() {
    addOptimisticLike(1); // shows immediately
    const updated = await likePost(postId); // real request, runs in the background
    setLikes(updated.likes); // reconciles with the server's real count
  }

  return <button onClick={handleLike}>❤️ {optimisticLikes}</button>;
}

useOptimistic(state, updateFn) takes the real, confirmed state and an updater, and returns a temporary "optimistic" value plus a function to set it. Whenever the surrounding async work finishes — whether likePost above resolves or throws — React automatically reverts optimisticLikes back to whatever likes actually is, with no onError to write by hand. The trade-off: this optimistic value is local to this component's state; it doesn't update a shared cache the way queryClient.setQueryData does, so a different component reading the same "real" like count elsewhere won't see it.

Which one do I reach for?

TanStack's onMutate/onError/onSettled pattern (Section 3) when the optimistic value needs to be visible anywhere else that reads the same query. useOptimistic when it's genuinely local to one component, or when there's no query cache at all to update — its most common home is pairing with a Server Action's form submission, which you'll do directly in Week 20.

7. Hands-on Exercise

Hands-on

Build a todo app with optimistic toggling and a cancellable search

Combine mutations, invalidation, optimistic updates, and cancellation in one app.

Requirements:

  1. Simulate a small backend: an in-memory todos array plus fetchTodos, createTodo, toggleTodo, and deleteTodo functions with artificial delay (~300–500ms).
  2. Build the todo list with useQuery, an add form with useMutation + invalidateQueries on success, and a delete button per item, also using invalidation.
  3. Make the checkbox toggle fully optimistic using the four-callback pattern from Section 3 — including a rollback path. Temporarily make toggleTodo randomly fail 30% of the time and confirm the checkbox visually reverts on failure.
  4. Add a search input above the list that filters todos server-side (simulate with your in-memory array) via a query keyed on ['todos', 'search', searchText], using the signal pattern from Section 5. Confirm — via a console log inside the simulated fetch — that typing quickly cancels superseded requests instead of letting every keystroke's request complete.
Hint

To make your simulated fetch actually respect the AbortSignal, check signal.aborted inside the setTimeout callback (or listen for the signal's abort event) before resolving — a plain setTimeout with no signal handling will still "complete" even after the real fetch equivalent would have been cancelled.

Part 2 — Contrast with useOptimistic:

You already made the checkbox toggle optimistic through TanStack Query's cache. Now make the "add todo" form optimistic a completely different way, so you feel the difference between the two approaches directly.

  1. In the add-todo form, wrap the todos array with useOptimistic instead of touching the query cache: const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (current, newTodo: Todo) => [...current, newTodo]).
  2. On submit, call addOptimisticTodo with a locally-constructed temporary todo (a fake negative id is fine) before calling mutate, so the new item appears in the list instantly, then let the existing onSuccess invalidation replace it with the real server-confirmed list.
  3. Render optimisticTodos instead of the raw query data in the list.
  4. Temporarily make createTodo fail every time and confirm the optimistically-added item disappears cleanly once the mutation rejects — with no onError rollback code of your own.
  5. In a short code comment, note which of the two approaches (Section 3's manual TanStack rollback, or this useOptimistic version) needed less code for the same guarantee, and why the checkbox toggle still makes more sense to keep on the TanStack approach even after seeing this.
Hint

The checkbox toggle should stay on the Section 3 pattern on purpose: the todo's done state might be read by other queries (a "completed count" elsewhere, a filtered view) that useOptimistic's component-local state can't reach. The add-todo list, by contrast, is only ever rendered in this one place — a good sign useOptimistic is the simpler correct choice here.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why doesn't a successful useMutation call automatically update a useQuery list elsewhere in the app?

Mutations and queries are separate cache operations by design — TanStack Query has no way to know that a given mutation's result should affect a specific query's cached data unless you tell it to. That's exactly what invalidateQueries (or a manual setQueryData) is for: explicitly connecting a write to the reads it should affect.

Q2

In an optimistic update, what is the value returned from onMutate used for?

It becomes the context argument passed to onError (and onSettled), most commonly used to carry a snapshot of the cache taken right before the optimistic change was applied. If the mutation fails, onError uses that snapshot to roll the cache back to exactly what it was before the optimistic update.

Q3

What's the practical difference between isPending and isFetching on a query?

isPending is true only when there's no cached data at all yet — the very first load. isFetching is true any time a request is in flight, including a background refetch of data that's already cached and being shown. Using the right one lets you show a full loading skeleton only on first load, and a subtler indicator during background refreshes.

Q4

How is passing signal to fetch different from Week 2's hand-written ignore flag?

The ignore flag lets a stale request finish and simply discards its result — the network call still runs to completion, wasting bandwidth and server work. An AbortSignal passed to fetch actually terminates the in-flight request at the network level the moment it's superseded, which is real cancellation rather than after-the-fact filtering.