FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·109·16 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

When an application changes server truth, that operation is a mutation. A query reads a representation of server state; a mutation asks the server to create, change, or remove something.

Typical mutations include:

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

The smallest v5 mutation looks like this:

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

The important detail is the single options object. In v5, the mutation function and the lifecycle callbacks belong inside that object:

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. This matters when you compare examples with older documentation or code written for v4.

Mutation UI

A mutation exposes the state of the write lifecycle. That state is useful for disabling the submit control, showing progress, and rendering an error, but it is not the task list itself.

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>
  );
}

isPending describes this particular write. isError describes its failure state. The task list should continue to come from its query, which can then be invalidated or updated when the write succeeds.

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

Invalidation

After a successful mutation, any cached projection that depends on the changed server data may now be stale. For example, creating a task can make a task list stale even though the create mutation itself succeeded.

jsx
const queryClient =
  useQueryClient();

const mutation =
  useMutation({
    mutationFn:
      createTask,

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

Invalidation marks matching queries stale and, where appropriate, causes them to refetch. It is often the safest default when the mutation can affect sorting, filtering, counts, or list membership that the client does not want to reproduce manually.

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. Whether that is desirable depends on the product meaning of “saved”; the distinction appears again later in the lesson.

Update cache from mutation response

Sometimes the server returns the authoritative updated task. In that case, there is no reason to refetch that task detail merely to obtain data the client already has:

jsx
const mutation =
  useMutation({
    mutationFn:
      updateTask,

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

setQueryData writes the server’s authoritative response into the detail cache. This avoids an unnecessary refetch for data already returned by the server.

For list projections, invalidation may still be simpler if sorting or filter membership can change. A detail response tells you what the entity is; it may not tell you every place in which that entity belongs.

UI-level optimistic update

There are two useful places to present optimistic state. The UI can render the submitted variables while the mutation is pending, or the query cache can be changed so that every consumer sees the prediction. Start with the UI-level approach when only one screen needs the temporary presentation.

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>
    </>
  );
}

The pending list item is derived from variables, so it can appear immediately without pretending that the server has already assigned an ID or accepted the write. submittedAt gives the temporary row a stable key for this pending submission.

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. The trade-off is that the cache now contains a prediction, so the mutation must be able to restore the previous value if the server rejects the write.

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 sequence is deliberate:

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

Cancelling prevents an older in-flight read from immediately replacing the prediction. The snapshot gives onError a known value to restore. Invalidation at settlement then lets the server’s result become authoritative, whether the write succeeded or failed.

Optimistic IDs

Creating an item optimistically can require a temporary identity. That identity is useful for rendering a pending row, but it is not automatically the identity that the server will assign.

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. In practice, that can mean replacing the temporary row with the returned entity, or removing the temporary presentation and letting an invalidated query supply the canonical item.

Mutation concurrency

Two writes may be pending at the same time. A single global flag does not tell you which row is saving, whether one operation failed, or whether another operation is still in flight.

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. This lets the UI represent independent writes instead of collapsing them into one unrelated application-wide state value.

Error classification

Do not retry every mutation automatically. A failed write is not necessarily transient, and retrying a request can duplicate a side effect.

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. The user needs a chance to correct the input.

A destructive write should consider idempotency and duplicate-submit behavior. A network error can occur after the server has already processed a request, so the client cannot always infer that retrying is harmless.

Pagination

For page-based data, the page and every filter that changes the result belong in the query key. That gives each logical result set its own cache identity:

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

placeholderData can keep the previous page visible while the next page is requested. The query function receives an abort signal, which should be passed to the request layer when supported.

Do not store each page in a separate manual array unless the UI intentionally accumulates pages. Let the query key identify the page when the UI is page-oriented; use an infinite query when accumulation is the intended behavior.

Infinite queries

Cursor-based APIs return a token that identifies where the next slice begins. TanStack Query keeps the individual pages and their parameters in its infinite-query data structure:

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 at the rendering boundary:

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

The cache should remain in the structure expected by the infinite-query API. A flat array is convenient for rendering, but it cannot replace the page and cursor metadata that TanStack Query needs to fetch the next page or manage retained pages.

Infinite query edge cases

Test the behavior around the boundaries, not just the successful first request:

  • 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. That limit is useful, but it also means the application should be deliberate about navigation and refetch behavior when older pages are discarded.

Query invalidation granularity

Invalidation can be broad or targeted. A broad key matches the task hierarchy:

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

A specific key limits the effect to one detail record:

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

Choose based on what the mutation can make stale. Completing a task may affect its detail query, several filtered lists, and dashboard counts; a change that only affects one detail view may not need the broad hierarchy.

Do not invalidate the entire application after every write. Overly broad invalidation creates unnecessary requests and makes it harder to understand which data actually depends on the mutation.

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. React’s transition behavior does not replace query invalidation, and query invalidation does not replace the form Action’s job of coordinating the form interaction.

Common mistakes

Watch for these failure modes:

  • 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. The client’s loading state, retry policy, rollback behavior, and error UI all depend on what the endpoint means.

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 409 is not interchangeable with a 422: one may require conflict resolution, while the other usually points to invalid input or an invalid state transition.

A mutation library cannot decide whether 409 should:

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

That is domain architecture. TanStack Query can coordinate the lifecycle, but the product and API contract must define the correct response to each failure class.

Mutation keys

You can add mutation keys:

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

This helps inspect and filter mutation state across components.

Do not make mutation keys copy query-key design blindly; they identify write workflows, not cached server-resource values. A query key answers “which data is this?” A mutation key is more about “which kind of write is in progress?”

Mutation variables

Prefer one meaningful object that states the inputs to the write:

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.

They also make a pending mutation inspectable: the variables explain what the user tried to do without requiring someone to reconstruct the component’s captured state.

mutate versus mutateAsync

Use mutate when callbacks own lifecycle:

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

Use mutateAsync when the caller needs Promise composition:

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

For example, navigation that depends on the returned task ID is naturally expressed with mutateAsync. A local lifecycle callback is often clearer when the mutation owns invalidation and presentation state.

Do not mix both styles unnecessarily in the same workflow. Pick the form that makes ownership of success and failure behavior obvious.

Invalidation strategy

Suppose mutation updates task t1. The same entity may appear in several cached projections:

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

Completing t1 can affect all of them. It may leave the detail value changed, move the task between filtered lists, and change a dashboard count.

You have choices.

Invalidate hierarchy

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

This is simple and safe when task caches share a hierarchy. It asks the query layer to reconcile the relevant projections instead of making this mutation understand every list shape.

Update detail + invalidate projections

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

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

This is useful because the detail response is authoritative while list membership or order may have changed. The detail view can update directly, and the list queries can obtain their correct membership from the server.

Update every projection manually

Possible, but complex.

Only do it when you understand every filter and sort projection. The apparent performance benefit of avoiding one refetch is not worth silently leaving one related cache entry wrong.

Correctness usually matters more than saving one refetch.

Optimistic cache update step-by-step

The earlier toggle example showed the complete lifecycle. Here it is broken into the decisions you need to make when implementing one.

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. It does not make the server write succeed; it protects the temporary client prediction while the write is being attempted.

2. Snapshot

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

For one cache:

jsx
const previous = queryClient.getQueryData(key);

If the mutation can affect multiple list caches, the snapshot and rollback need to cover those caches, not just whichever list happens to be visible in one component.

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,
    ),
  };
});

The prediction should change only the part of the cached value that the mutation can justify. If completion also changes ordering or list membership, a direct field patch may not be enough; settlement invalidation is what restores those server-defined details.

4. Return rollback context

jsx
return { previous };

The value returned from onMutate becomes the context supplied to the later lifecycle callbacks. Keep it tied to this mutation attempt rather than storing a single shared snapshot elsewhere.

5. Roll back on failure

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

The rollback restores the state that existed before this optimistic attempt. In production code, guard the context when a snapshot may not exist and consider whether concurrent mutations require a narrower rollback than a whole-object replacement.

6. Reconcile

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

This restores server authority. Even a successful optimistic update can be incomplete if the server normalizes a value, applies permissions, changes ordering, or updates related fields.

Concurrent optimistic mutations

This is where simple examples often fail. A snapshot that is safe for one mutation can be destructive when another mutation changes the same entity before the first one settles.

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. The rollback is correct relative to A’s starting point but incorrect relative to the newer state.

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. The more independently editable fields and consumers a resource has, the more carefully the client must define what a rollback is allowed to overwrite.

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. For example, sequential draft saves for one task may need to preserve ordering:

Example concept:

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

Do not serialize all mutations globally; only workflows whose ordering matters. Independent tasks or unrelated operations should not wait on one another without a domain reason.

useMutationState

A component elsewhere can observe matching mutation state. This is useful when the form lives in one component but the list should show pending created tasks.

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. The list can render a pending representation based on the write’s variables while leaving server-state cache data untouched.

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. A button may continue to say “Saving…” after the server has accepted the write because dependent visible data is still reconciling.

Decide whether:

text
"saved" means server accepted mutation

or:

text
"saved" means dependent visible data has reconciled

Neither interpretation is universally correct. The important part is choosing deliberately and making the pending label match that choice.

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. The rendered flat list is a derived view; the cache must retain both the pages and the parameters that produced them.

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. That is different from inventing another cursor or continuing to request pages after the API has indicated that the result is complete.

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. isFetching can represent any fetch, while isFetchingNextPage identifies the specific operation initiated to append another page.

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. The correct page depends on the server’s filtering, ordering, and pagination rules, and those rules may change between the client prediction and the response.

Often:

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

is safer. The temporary row gives immediate feedback without pretending to know where the canonical item belongs in the paginated cache.

Offline mutation considerations

If a product promises offline writes, mutation persistence requires more than retrying a failed request. It requires a protocol and a recovery model.

That model may need:

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

Do not claim “offline mutations” simply because a request retries after reconnect. A retry may still lose the operation, duplicate its side effects, or have no defined behavior when the server state changed while the client was offline.

Error rendering by class

Mutation UI should distinguish the failure class because the next user action differs.

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. A generic message hides whether the user should fix a field, sign in, resolve a conflict, retry, or wait for an operational issue to be handled.

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. Removing a row changes the interaction surface, so the design should account for confirmation, recovery, pending feedback, and focus management rather than only removing data from the cache.

Devtools workflow

When a mutation feels wrong, inspect it in an order that separates input, lifecycle, cache, and reconciliation:

  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.

This workflow helps identify whether the problem is the submitted input, the mutation lifecycle, the optimistic patch, the rollback, or a second state store disagreeing with the query cache.

Deep-dive 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. The client can predict a result, but only the server can decide whether the version supplied with the write is still current.

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 because the first request may have succeeded even when the client did not receive its response.

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. From the client’s perspective, “no response” is not proof that “no side effect happened.”

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.

Reader page: /react/lesson/109/tanstack-query-v5-mutations-invalidation-optimistic-updates-and-infinite-data