skip to content

In RTK Query, how do you make an updatePost mutation update the cached post optimistically and roll back if the request fails?

level: seniorimportance: should knowfreq 40%

answer

  1. lifecycle callback on the mutation
  2. patch the existing entry
  3. keep the patch result
  4. await, catch, undo

basics

~20 s

In RTK Query, give the mutation an onQueryStarted callback that dispatches api.util.updateQueryData to patch the cached post, keeps the returned patchResult, awaits queryFulfilled, and calls patchResult.undo() if it rejects. Invalidating tags on failure is the safer rollback when edits overlap.

solid answer

~40 s

`onQueryStarted(arg, { dispatch, queryFulfilled })` runs as soon as the mutation starts. Inside it, `const patchResult = dispatch(api.util.updateQueryData('getPost', arg.id, (draft) => { Object.assign(draft, patch) }))` edits the cached `getPost(id)` entry with an Immer draft, so every subscriber re-renders at once. `patchResult` holds the patches, the inverse patches and an `undo()` function. Then `try { await queryFulfilled } catch { patchResult.undo() }`. Patch every cache entry that shows the post — the detail and the list — and undo each on failure. Two traps: `updateQueryData` only updates an **existing** entry (for a missing one the recipe is not called — use `upsertQueryData` to create); and with several overlapping edits, undoing one patch can restore stale data, so the docs recommend invalidating the tags on error instead.

code

ts · 39 lines
ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

type Post = { id: number; title: string; body: string };

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({ query: () => 'posts' }),
    getPost: build.query<Post, number>({
      query: (id) => `posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    updatePost: build.mutation<Post, Pick<Post, 'id'> & Partial<Post>>({
      query: ({ id, ...patch }) => ({ url: `posts/${id}`, method: 'PATCH', body: patch }),
      async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
        const detailPatch = dispatch(
          api.util.updateQueryData('getPost', id, (draft) => {
            Object.assign(draft, patch);
          }),
        );
        const listPatch = dispatch(
          api.util.updateQueryData('getPosts', undefined, (draft) => {
            const post = draft.find((p) => p.id === id);
            if (post) Object.assign(post, patch);
          }),
        );
        try {
          await queryFulfilled;
        } catch {
          detailPatch.undo();
          listPatch.undo();
          // With overlapping edits, prefer:
          // dispatch(api.util.invalidateTags([{ type: 'Post', id }]));
        }
      },
    }),
  }),
});

go deeper

for a junior

Know the three names: onQueryStarted, updateQueryData, and the undo on the patch result.

for a middle

Explain the step order, why the argument must match the cached entry, and updateQueryData versus upsertQueryData.

for a senior

Patch every affected entry, choose undo or invalidate-on-error based on concurrency, and decide optimistic versus pessimistic per mutation.

for a principal

Set a policy for which mutations may be optimistic and how failures are surfaced consistently to users.

## The goal A user renames a post. The request takes a second; the UI should show the new title immediately, and if the server rejects the change, it should go back. In RTK Query this is an **optimistic update**, built from three pieces: - **`onQueryStarted`** — a lifecycle callback on the endpoint that runs when the mutation starts. - **`api.util.updateQueryData`** — a thunk that edits an existing cache entry with an Immer recipe. - **`queryFulfilled`** — a promise that resolves when the request succeeds and rejects when it fails. ## Step by step 1. Define `onQueryStarted(arg, { dispatch, queryFulfilled })` on the `updatePost` mutation. 2. Dispatch `api.util.updateQueryData('getPost', arg.id, (draft) => { Object.assign(draft, patch) })`. The draft is an Immer draft of the cached post: mutate it and RTK Query produces the immutable update. 3. Keep the returned **`patchResult`**. It contains `patches`, `inversePatches` and an **`undo()`** function that applies the inverse. 4. Repeat for every other cache entry showing the post, such as the `getPosts` list. 5. `await queryFulfilled` inside `try`. 6. In `catch`, call `undo()` on each patch result. Every component subscribed to the patched entries re-renders at step 2, before the network responds. ## What updateQueryData will and will not do | Utility | Existing entry | Missing entry | |---|---|---| | `updateQueryData(endpoint, arg, recipe)` | Patches it | Recipe is **not called**; no patches returned | | `upsertQueryData(endpoint, arg, value)` | Replaces it | Creates it | So an optimistic update only shows where the data is already cached. The endpoint name and **argument** must match exactly the arguments the cached entry was created with: `updateQueryData('getPost', 5, ...)` does not touch an entry created with `'5'`. ## Rollback: undo versus invalidation The documentation offers two ways to recover when `queryFulfilled` rejects: - **`patchResult.undo()`** — applies the inverse patches. Simple and instant. - **`dispatch(api.util.invalidateTags([{ type: 'Post', id }]))`** — throws the optimistic state away and refetches the truth. Its tip matters in production: when many mutations can fire in quick succession, undoing one patch can race with the others — an undo computed against an earlier state can restore a value a later successful edit had already replaced. In those scenarios, **invalidate on error** instead of undoing. ## Optimistic versus pessimistic A **pessimistic** update waits for the server: `const { data } = await queryFulfilled`, then `updateQueryData` with the server's response, or `upsertQueryData` to create an entry for a newly created resource. It avoids rollback entirely at the cost of the round trip before the UI changes. Use optimistic updates where failures are rare and latency is visible — renames, toggles, reorders — and pessimistic ones where the server decides the result, such as generated ids or computed fields. ## Interplay with tags If `updatePost` also has `invalidatesTags` for the post, a success triggers a refetch after the optimistic patch; the refetched data replaces the patched data, which is usually what you want. If the refetch is pure overhead, drop the tag for this mutation and rely on the patch, or apply the server response pessimistically. ## Common mistakes - **Awaiting nothing**: dispatching the patch but never checking `queryFulfilled`, so failures leave false data on screen. - **Patching only the detail** while the list still shows the old title. - **Using `updateQueryData` to create an entry** that does not exist yet. - **Passing a different argument shape** than the one the entry was cached with, so nothing is patched.

  • In RTK Query, what happens if updateQueryData targets an endpoint and argument that are not cached?
    Nothing: the recipe is not called and no patches are returned, so there is nothing to undo either. `updateQueryData` only edits existing entries. To create an entry — for example, caching a newly created post under `getPost(newId)` — use `upsertQueryData` after the response arrives.
  • Why can patchResult.undo() restore the wrong value when several RTK Query edits overlap?
    Each undo applies inverse patches computed when that patch was made. If edit A fails after edit B already succeeded, A's inverse patch can restore the value from before A, overwriting B's result. The docs recommend invalidating the affected tags on error in these cases, so the cache is refetched from the server.
  • How would you turn the RTK Query optimistic update into a pessimistic one?
    Remove the patch before the request. In `onQueryStarted`, `const { data: saved } = await queryFulfilled`, then dispatch `updateQueryData('getPost', saved.id, (draft) => { Object.assign(draft, saved) })`. The UI changes only after the server confirms, and no rollback is needed.

saying these in an interview costs you the question

  • updateQueryData creates the cache entry if it does not exist
  • RTK Query rolls optimistic patches back automatically on failure
  • onQueryStarted runs only after the request succeeds
  • Patching the detail entry also updates the list entry
  • undo() is always safe, even with many overlapping mutations