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 13 Week 9 of 20 ~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. 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.

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