Module: React and Ecosystem
React and Ecosystem·109·8 MIN READ

109: TanStack Query v5 Mutations, Invalidation, Optimistic Updates, and Infinite Data

TOPICS COVERED: TanStack Query v5 Mutations, Invalidation, Optimistic Updates, and Infinite Data

Learning objectives

You will learn to:

  • use useMutation with TanStack Query v5 object syntax;
  • separate mutation state from query state;
  • invalidate related queries;
  • update cache from authoritative mutation responses;
  • implement optimistic UI through mutation variables;
  • implement optimistic cache updates with rollback;
  • coordinate concurrent mutations;
  • build page and cursor-based infinite queries;
  • avoid old v4 mutation signatures.

Mutations represent server writes

A mutation changes server truth.

Examples:

  • create task;
  • rename task;
  • complete task;
  • delete task;
  • upload file;
  • save profile.

Basic v5 mutation:

jsx
const createTaskMutation =
  useMutation({
    mutationFn:
      createTask,
  });

Correct v5 syntax:

jsx
useMutation({
  mutationFn: saveTask,
  onSuccess: ...,
});

Keep every mutation example on the TanStack Query v5 single-object API; older positional mutation overloads are intentionally excluded from this course.

Mutation UI

jsx
function NewTaskButton() {
  const mutation =
    useMutation({
      mutationFn:
        createTask,
    });

  function add() {
    mutation.mutate({
      title:
        'Review invalidation',
    });
  }

  return (
    <div>
      <button
        onClick={add}
        disabled={
          mutation.isPending
        }
      >
        {mutation.isPending
          ? 'Saving…'
          : 'Create task'}
      </button>

      {mutation.isError && (
        <p role="alert">
          Could not create
          task.
        </p>
      )}
    </div>
  );
}

Do not treat a mutation as a permanent cache for the task list. It represents the write lifecycle.

Invalidation

After a mutation succeeds, related cached projections may be stale.

jsx
const queryClient =
  useQueryClient();

const mutation =
  useMutation({
    mutationFn:
      createTask,

    onSuccess:
      async () => {
        await queryClient
          .invalidateQueries({
            queryKey:
              ['tasks'],
          });
      },
  });

Awaiting invalidation keeps the mutation pending until the relevant invalidation work completes.

That can improve pending semantics when the UI should not claim completion until fresh data is available.

Update cache from mutation response

If the server returns the authoritative updated task, use it.

jsx
const mutation =
  useMutation({
    mutationFn:
      updateTask,

    onSuccess:
      (task) => {
        queryClient
          .setQueryData(
            [
              'task',
              task.id,
            ],
            task,
          );
      },
  });

This avoids an unnecessary refetch for data already returned by the server.

For list projections, invalidation may still be simpler if sorting/filter membership can change.

UI-level optimistic update

TanStack Query v5 provides a simple optimistic pattern using mutation variables.

jsx
function TaskList() {
  const tasksQuery =
    useQuery({
      queryKey: ['tasks'],
      queryFn: getTasks,
    });

  const addMutation =
    useMutation({
      mutationFn:
        createTask,
      onSettled:
        () =>
          queryClient
            .invalidateQueries({
              queryKey:
                ['tasks'],
            }),
    });

  if (
    tasksQuery.isPending
  ) {
    return <p>Loading…</p>;
  }

  return (
    <>
      <ul>
        {tasksQuery.data
          .tasks
          .map((task) => (
            <li key={task.id}>
              {task.title}
            </li>
          ))}

        {addMutation
          .isPending && (
          <li
            key={
              addMutation
                .submittedAt
            }
          >
            {
              addMutation
                .variables
                .title
            }
            {' '}
            (saving…)
          </li>
        )}
      </ul>
    </>
  );
}

This is often safer than modifying the cache when only one screen needs optimistic presentation.

Optimistic cache update with rollback

Use cache-level optimism when several consumers need to observe the temporary value.

jsx
const toggleMutation =
  useMutation({
    mutationFn:
      toggleTask,

    onMutate:
      async ({
        id,
        completed,
      }) => {
        await queryClient
          .cancelQueries({
            queryKey:
              ['tasks'],
          });

        const previous =
          queryClient
            .getQueryData(
              ['tasks'],
            );

        queryClient
          .setQueryData(
            ['tasks'],
            (current) => {
              if (!current) {
                return current;
              }

              return {
                ...current,
                tasks:
                  current.tasks
                    .map(
                      (task) =>
                        task.id === id
                          ? {
                              ...task,
                              completed,
                            }
                          : task,
                    ),
              };
            },
          );

        return {
          previous,
        };
      },

    onError:
      (
        error,
        variables,
        context,
      ) => {
        if (
          context?.previous
        ) {
          queryClient
            .setQueryData(
              ['tasks'],
              context.previous,
            );
        }
      },

    onSettled:
      () =>
        queryClient
          .invalidateQueries({
            queryKey:
              ['tasks'],
          }),
  });

The steps are:

text
cancel
snapshot
optimistically update
attempt server write
rollback on error
reconcile / invalidate

Optimistic IDs

Creating an item optimistically can require a temporary identity.

Do not permanently treat a client-generated temporary ID as the server ID unless the API contract supports client-assigned IDs.

Reconcile the server result.

Mutation concurrency

Two writes may be pending at the same time.

Do not use one global boolean such as:

jsx
const [saving, setSaving] =
  useState(false);

for every row.

Mutation instances and mutation keys can model specific workflows.

TanStack Query also provides mutation-state tools for observing pending mutations across components.

Error classification

Do not retry every mutation automatically.

A mutation may fail because of:

  • 400 malformed input;
  • 401 unauthenticated;
  • 403 forbidden;
  • 409 conflict;
  • 422 validation;
  • 429 rate limit;
  • 500 transient server failure;
  • offline/network failure.

A 422 should usually return field guidance rather than be retried repeatedly.

A destructive write should consider idempotency and duplicate-submit behavior.

Pagination

Page number belongs in the query key:

jsx
useQuery({
  queryKey: [
    'tasks',
    {
      page,
      status,
    },
  ],
  queryFn: ({ signal }) =>
    getTasks({
      page,
      status,
      signal,
    }),
  placeholderData:
    (previous) =>
      previous,
});

Do not store each page in a separate manual array unless the UI intentionally accumulates pages.

Infinite queries

Cursor-based data:

jsx
const query =
  useInfiniteQuery({
    queryKey: [
      'tasks',
      {
        status,
      },
    ],

    queryFn:
      ({
        pageParam,
        signal,
      }) =>
        getTasksByCursor({
          status,
          cursor:
            pageParam,
          signal,
        }),

    initialPageParam:
      null,

    getNextPageParam:
      (lastPage) =>
        lastPage
          .nextCursor
        ?? undefined,

    maxPages: 5,
  });

Flatten only for rendering:

jsx
const tasks =
  query.data?.pages
    .flatMap(
      (page) =>
        page.tasks,
    )
  ?? [];

The cache should remain in the structure expected by the infinite-query API.

Infinite query edge cases

Test:

  • last page has no cursor;
  • filter changes;
  • duplicate rows across pages;
  • item deleted from earlier page;
  • retry after partial failure;
  • rapid Next/Load More clicks;
  • memory behavior with many pages.

maxPages can limit retained pages for large infinite lists.

Query invalidation granularity

Broad:

jsx
invalidateQueries({
  queryKey: ['tasks'],
});

Specific:

jsx
invalidateQueries({
  queryKey: [
    'task',
    taskId,
  ],
});

Choose based on what the mutation can make stale.

Do not invalidate the entire application after every write.

React Action integration

A React 19 form Action can call a mutation or server function and then coordinate cache invalidation.

The Action handles UI transition semantics.

TanStack Query handles server cache semantics.

Keep responsibilities clear.

Common mistakes

  • positional mutation syntax from older versions;
  • optimistic write without rollback;
  • forgetting to cancel related queries before cache optimism;
  • invalidating too broadly;
  • never invalidating after server write;
  • using mutation result as permanent list state;
  • retrying authorization/validation failures;
  • not reconciling temporary IDs;
  • using page data after filter/key changed.

Exercises

  1. Build create mutation with invalidation.
  2. Update a task detail cache from mutation response.
  3. Build optimistic toggle with rollback.
  4. Build UI-level optimistic add using mutation variables.
  5. Add page pagination with previous data placeholder.
  6. Build a cursor infinite query with maxPages.
  7. Force a 500 and verify optimistic rollback.

Exit questions

  1. What is the role of onMutate?
  2. Why cancel queries before optimistic cache updates?
  3. When is invalidation safer than setQueryData?
  4. What is the difference between UI-level and cache-level optimism?
  5. Why does page/filter belong in the query key?
  6. What is maxPages for?

Official references


Deep dive: mutation design starts with server semantics

Before writing useMutation, document the write contract.

For example:

text
PATCH /api/tasks/:id

Request:

json
{
  "completed": true,
  "version": 7
}

Possible responses:

text
200 updated task
401 unauthenticated
403 forbidden
404 task missing
409 version conflict
422 invalid transition
500 unexpected failure

Your mutation UX depends on those meanings.

A mutation library cannot decide whether 409 should:

  • retry automatically;
  • show conflict dialog;
  • refetch and discard draft;
  • merge fields.

That is domain architecture.

Mutation keys

You can add mutation keys:

jsx
useMutation({
  mutationKey: ['tasks', 'toggle'],
  mutationFn: toggleTask,
});

This helps inspect/filter mutation state across components.

Do not make mutation keys copy query-key design blindly; they identify write workflows, not cached server-resource values.

Mutation variables

Prefer one meaningful object:

jsx
mutation.mutate({
  id: task.id,
  completed: true,
});

instead of closing over many hidden component values:

jsx
mutation.mutate();

when the mutation function secretly reads changing state.

Explicit variables improve:

  • retries;
  • optimistic UI;
  • testing;
  • Devtools;
  • shared mutation state.

mutate versus mutateAsync

Use mutate when callbacks own lifecycle:

jsx
mutation.mutate(input, {
  onSuccess(data) {
    ...
  },
});

Use mutateAsync when caller needs Promise composition:

jsx
try {
  const task = await mutation.mutateAsync(input);
  navigate(`/tasks/${task.id}`);
} catch (error) {
  ...
}

Do not mix both styles unnecessarily in the same workflow.

Invalidation strategy

Suppose mutation updates task t1.

Potential cache entries:

text
['task', 't1']
['tasks', 'list', { status: 'open' }]
['tasks', 'list', { status: 'done' }]
['dashboard', 'counts']

Completing t1 can affect all of them.

You have choices.

Invalidate hierarchy

jsx
await queryClient.invalidateQueries({
  queryKey: ['tasks'],
});

Simple and safe when task caches share hierarchy.

Update detail + invalidate projections

jsx
queryClient.setQueryData(
  ['task', task.id],
  task,
);

await queryClient.invalidateQueries({
  queryKey: ['tasks', 'list'],
});

Useful because detail response is authoritative while list membership/order may have changed.

Update every projection manually

Possible, but complex.

Only do it when you understand every filter/sort projection.

Correctness usually matters more than saving one refetch.

Optimistic cache update step-by-step

Toggle example.

1. Cancel conflicting reads

jsx
await queryClient.cancelQueries({
  queryKey: ['tasks'],
});

This helps prevent an in-flight older response from immediately overwriting the optimistic state.

2. Snapshot

jsx
const previousLists = queryClient.getQueriesData({
  queryKey: ['tasks', 'list'],
});

For one cache:

jsx
const previous = queryClient.getQueryData(key);

3. Write prediction

jsx
queryClient.setQueryData(key, (current) => {
  if (!current) return current;

  return {
    ...current,
    tasks: current.tasks.map((task) =>
      task.id === variables.id
        ? { ...task, completed: variables.completed }
        : task,
    ),
  };
});

4. Return rollback context

jsx
return { previous };

5. Roll back on failure

jsx
onError(error, variables, context) {
  queryClient.setQueryData(key, context.previous);
}

6. Reconcile

jsx
onSettled() {
  return queryClient.invalidateQueries({
    queryKey: ['tasks'],
  });
}

This restores server authority.

Concurrent optimistic mutations

This is where simple examples often fail.

Imagine:

text
Mutation A → completed true
Mutation B → title changed
A fails
B succeeds

If A rollback restores an entire old task object, it might erase B's successful title.

Solutions can include:

  • narrower rollback patch;
  • version-aware server model;
  • mutation serialization by scope;
  • no cache-level optimism for conflicting fields;
  • UI-level optimism instead;
  • refetch/reconcile after settlement.

Optimism is a concurrency problem, not a visual trick.

Mutation scope / serialization

TanStack Query v5 supports mutation scope behavior for serializing mutations with the same scope ID.

This can be useful when a workflow must not run concurrently.

Example concept:

jsx
useMutation({
  mutationFn: saveDraft,
  scope: {
    id: `task-${taskId}`,
  },
});

Do not serialize all mutations globally; only workflows whose ordering matters.

useMutationState

A component elsewhere can observe matching mutation state.

For example, show pending created tasks in a list while the form lives elsewhere.

Conceptually:

jsx
const pendingCreates = useMutationState({
  filters: {
    mutationKey: ['tasks', 'create'],
    status: 'pending',
  },
  select: (mutation) => ({
    variables: mutation.state.variables,
    submittedAt: mutation.state.submittedAt,
  }),
});

This is especially useful for optimistic UI without mutating query cache.

Invalidation and awaiting

If onSuccess returns the invalidation Promise:

jsx
onSuccess: () => {
  return queryClient.invalidateQueries({
    queryKey: ['tasks'],
  });
},

the mutation can remain pending until invalidation work resolves.

This changes UI semantics.

Decide whether:

text
"saved" means server accepted mutation

or:

text
"saved" means dependent visible data has reconciled

Infinite-query mutation challenges

Imagine pages:

text
page 1: t1, t2
page 2: t3, t4

Deleting t2.

If you manually update infinite data, preserve structure:

jsx
queryClient.setQueryData(key, (data) => {
  if (!data) return data;

  return {
    ...data,
    pages: data.pages.map((page) => ({
      ...page,
      tasks: page.tasks.filter((task) => task.id !== id),
    })),
  };
});

Do not replace:

text
{ pages, pageParams }

with a flat array.

TanStack Query expects its infinite-data shape.

Cursor pagination

A cursor API should return a stable next cursor:

json
{
  "items": [...],
  "nextCursor": "eyJpZCI6..."
}

Query:

jsx
useInfiniteQuery({
  queryKey: ['tasks', 'infinite', filters],
  queryFn: ({ pageParam, signal }) =>
    getTasks({
      cursor: pageParam,
      filters,
      signal,
    }),
  initialPageParam: null,
  getNextPageParam: (lastPage) =>
    lastPage.nextCursor ?? undefined,
});

undefined communicates no next page.

fetchNextPage behavior

jsx
<button
  disabled={!query.hasNextPage || query.isFetchingNextPage}
  onClick={() => query.fetchNextPage()}
>
  {query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>

Distinguish:

text
isFetching

from:

text
isFetchingNextPage

so background refresh does not make the Load More button lie.

Invalidation after create in infinite list

New item may belong:

  • first page;
  • last page;
  • no current filter;
  • a different sorted location.

Manual optimistic insertion is easy to get wrong.

Often:

text
show optimistic submitted item separately
→ server succeeds
→ invalidate infinite query

is safer.

Offline mutation considerations

If a product promises offline writes, mutation persistence requires:

  • mutation defaults;
  • serializable variables;
  • resumed mutations;
  • conflict resolution;
  • idempotent server design.

Do not claim "offline mutations" simply because a request retries after reconnect.

Error rendering by class

Mutation UI should distinguish:

Validation

Show field errors.

Auth

Prompt login/permission.

Conflict

Show stale edit/reload/merge.

Network

Retry/keep pending draft.

Unexpected

Report and show safe fallback.

Avoid:

jsx
<p>Something went wrong</p>

for every case.

Destructive mutation UX

Delete:

jsx
const deleteMutation = useMutation({...});

Questions:

  • confirm?
  • undo?
  • optimistic remove?
  • disabled while pending?
  • can duplicate DELETE be safely retried?
  • does 404 on retry mean already deleted?
  • should focus move after row disappears?

Server/API semantics and accessibility matter as much as cache API.

Devtools workflow

When a mutation feels wrong:

  1. inspect mutation variables;
  2. inspect mutation status;
  3. inspect query cache before onMutate;
  4. inspect optimistic cache;
  5. force failure;
  6. confirm rollback;
  7. inspect invalidation/refetch;
  8. test concurrent mutation;
  9. verify no duplicated Redux/local copy.

Exercises

  1. Implement query-key hierarchy plus targeted invalidation.
  2. Build detail-cache update + list invalidation.
  3. Implement optimistic toggle with rollback.
  4. Force two concurrent edits and observe rollback collision.
  5. Replace cache optimism with useMutationState UI optimism.
  6. Build cursor infinite query with maxPages.
  7. Delete a record from infinite cached pages while preserving {pages, pageParams}.
  8. Design UX for 409 conflict.

Mastery check

Explain:

  • mutation variables;
  • invalidation versus direct cache update;
  • rollback context;
  • concurrent optimism hazards;
  • mutation scope;
  • useMutationState;
  • infinite query structure;
  • why server semantics determine mutation UX.

Production case study: optimistic update with entity version conflict

Server task:

json
{
  "id": "t1",
  "title": "Prepare invoice",
  "completed": false,
  "version": 8
}

Mutation sends:

json
{
  "completed": true,
  "version": 8
}

Another user edits first, server becomes version 9.

Your write returns:

text
409 Conflict

A blind optimistic rollback is not enough. The UI should:

  1. restore safe local cache;
  2. invalidate/refetch task;
  3. show conflict notice;
  4. let user retry against new version if appropriate.

This demonstrates why optimistic UI cannot bypass concurrency control.

For high-integrity domains, use:

  • version columns;
  • ETags/If-Match;
  • server transactions;
  • idempotency keys where relevant.

TanStack Query coordinates client cache; database/API still owns concurrency truth.


Additional depth: mutation retries and idempotency

Queries are naturally read-like. Retrying writes can be dangerous.

Suppose:

text
POST /payments

request reaches server and succeeds, but response is lost.

Client sees network error and retries.

Without idempotency, two charges can occur.

For critical create/write operations, API may support:

text
Idempotency-Key: unique-operation-id

or another transactional uniqueness mechanism.

TanStack Query's retry option is not a substitute for idempotent server design.

For ordinary safe writes, you might configure:

jsx
retry: false

or domain-specific retry logic.

Before enabling mutation retry, ask:

text
Can repeating this operation cause duplicate side effects?
Can server detect duplicate operation?
Does PUT/PATCH semantics make it safe?

Mutation reliability is a client + protocol + server concern.