109: TanStack Query v5 Mutations, Invalidation, Optimistic Updates, and Infinite Data
Learning objectives
You will learn to:
- use
useMutationwith 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:
const createTaskMutation =
useMutation({
mutationFn:
createTask,
});
Correct v5 syntax:
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
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.
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.
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.
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.
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:
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:
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:
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:
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:
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:
invalidateQueries({
queryKey: ['tasks'],
});
Specific:
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
- Build create mutation with invalidation.
- Update a task detail cache from mutation response.
- Build optimistic toggle with rollback.
- Build UI-level optimistic add using mutation variables.
- Add page pagination with previous data placeholder.
- Build a cursor infinite query with
maxPages. - Force a 500 and verify optimistic rollback.
Exit questions
- What is the role of
onMutate? - Why cancel queries before optimistic cache updates?
- When is invalidation safer than
setQueryData? - What is the difference between UI-level and cache-level optimism?
- Why does page/filter belong in the query key?
- What is
maxPagesfor?
Official references
- https://tanstack.com/query/latest/docs/framework/react/reference/useMutation
- https://tanstack.com/query/latest/docs/framework/react/guides/invalidations-from-mutations
- https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
- https://tanstack.com/query/latest/docs/framework/react/guides/updates-from-mutation-responses
- https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery
Deep dive: mutation design starts with server semantics
Before writing useMutation, document the write contract.
For example:
PATCH /api/tasks/:id
Request:
{
"completed": true,
"version": 7
}
Possible responses:
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:
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:
mutation.mutate({
id: task.id,
completed: true,
});
instead of closing over many hidden component values:
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:
mutation.mutate(input, {
onSuccess(data) {
...
},
});
Use mutateAsync when caller needs Promise composition:
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:
['task', 't1'] ['tasks', 'list', { status: 'open' }] ['tasks', 'list', { status: 'done' }] ['dashboard', 'counts']
Completing t1 can affect all of them.
You have choices.
Invalidate hierarchy
await queryClient.invalidateQueries({
queryKey: ['tasks'],
});
Simple and safe when task caches share hierarchy.
Update detail + invalidate projections
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
await queryClient.cancelQueries({
queryKey: ['tasks'],
});
This helps prevent an in-flight older response from immediately overwriting the optimistic state.
2. Snapshot
const previousLists = queryClient.getQueriesData({
queryKey: ['tasks', 'list'],
});
For one cache:
const previous = queryClient.getQueryData(key);
3. Write prediction
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
return { previous };
5. Roll back on failure
onError(error, variables, context) {
queryClient.setQueryData(key, context.previous);
}
6. Reconcile
onSettled() {
return queryClient.invalidateQueries({
queryKey: ['tasks'],
});
}
This restores server authority.
Concurrent optimistic mutations
This is where simple examples often fail.
Imagine:
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:
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:
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:
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ['tasks'],
});
},
the mutation can remain pending until invalidation work resolves.
This changes UI semantics.
Decide whether:
"saved" means server accepted mutation
or:
"saved" means dependent visible data has reconciled
Infinite-query mutation challenges
Imagine pages:
page 1: t1, t2 page 2: t3, t4
Deleting t2.
If you manually update infinite data, preserve structure:
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:
{ pages, pageParams }
with a flat array.
TanStack Query expects its infinite-data shape.
Cursor pagination
A cursor API should return a stable next cursor:
{
"items": [...],
"nextCursor": "eyJpZCI6..."
}
Query:
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
<button
disabled={!query.hasNextPage || query.isFetchingNextPage}
onClick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
Distinguish:
isFetching
from:
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:
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:
<p>Something went wrong</p>
for every case.
Destructive mutation UX
Delete:
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:
- inspect mutation variables;
- inspect mutation status;
- inspect query cache before
onMutate; - inspect optimistic cache;
- force failure;
- confirm rollback;
- inspect invalidation/refetch;
- test concurrent mutation;
- verify no duplicated Redux/local copy.
Exercises
- Implement query-key hierarchy plus targeted invalidation.
- Build detail-cache update + list invalidation.
- Implement optimistic toggle with rollback.
- Force two concurrent edits and observe rollback collision.
- Replace cache optimism with
useMutationStateUI optimism. - Build cursor infinite query with
maxPages. - Delete a record from infinite cached pages while preserving
{pages, pageParams}. - 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:
{
"id": "t1",
"title": "Prepare invoice",
"completed": false,
"version": 8
}
Mutation sends:
{
"completed": true,
"version": 8
}
Another user edits first, server becomes version 9.
Your write returns:
409 Conflict
A blind optimistic rollback is not enough. The UI should:
- restore safe local cache;
- invalidate/refetch task;
- show conflict notice;
- 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:
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:
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:
retry: false
or domain-specific retry logic.
Before enabling mutation retry, ask:
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.
