Mutations, optimistic updates and pagination
Change server data with useMutation(), show the change before the server confirms it and roll it back when it fails, then page through a long list without the page going blank.
What you'll learn
When you finish, you can do these.- 1Save a task with useMutation() and refresh every list
- 2Show a new task at once and undo it if saving fails
- 3Page through a long list without it going blank
- 4Load more at the end of a feed with useInfiniteQuery()
Picture a to-do app whose tasks live on a server. Showing the list is half the job: people want to add tasks too. Each new task has to reach the server, every list that shows tasks has to catch up, and the task should appear at once, before the server answers. With fifty tasks, the list also needs pages that do not go blank while the next one loads.
The Code tab holds that app, finished and built with TanStack Query: a form in AddTask.vue, the tasks ten to a page in TaskPages.vue, and a feed that loads more in TaskFeed.vue. A stand-in server answers after 0.6 seconds, and the Requests line counts its reads (GET) and writes (POST). You need one idea from the lesson Query libraries, TanStack Query and Pinia Colada: a query library keeps server data in one cache for the whole app, and useQuery() reads each entry by its key, an array such as ['tasks', 1].
Write with useMutation
A query reads; a mutation changes something on the server, such as adding a task. useMutation() takes a mutationFn, the async function that sends the change, and returns mutate(), which starts it, with isPending, isError and error for that one change. AddTask.vue passes addTask, the function in api.ts that sends a POST request, and disables Add while isPending is true.
const { mutate, isPending, isError, error } = useMutation({
mutationFn: addTask,
// onMutate and onError: shown below
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
})
The cache knows nothing about the new task yet. queryClient, which useQueryClient() returns, is the object that holds the cache. Its invalidateQueries() marks every entry whose key starts with ['tasks'] as stale, out of date: the pages at ['tasks', 1] and ['tasks', 2], and the feed at ['tasks', 'feed']. Those on screen refetch at once, the others when they are next used.
onSettled runs after a success and after a failure, so the list is checked against the server either way. It returns the promise from invalidateQueries(), so Add stays disabled until the list is fresh.
Queries retry a failed request three times by default; mutations do not. A POST that reached the server but lost its answer would be sent again, and the task created twice. Set retry on a mutation only when repeating it is safe, such as marking a task done.
Show it first, roll back on failure
An optimistic update writes the expected result into the cache before the server answers, then repairs it if the request fails. The visitor sees the task at once, and the server still has the last word.
- Click Add
- Optimisticcache updated
- Request
- Successinvalidate
- or Errorroll back
In AddTask.vue that work happens in onMutate, a function the mutation runs before it sends the request.
onMutate: async (newTitle: string) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] })
const previous = queryClient.getQueryData<TaskPage>(['tasks', 1])
queryClient.setQueryData<TaskPage>(['tasks', 1], old =>
old && { ...old, items: [{ id: -Date.now(), title: newTitle, saving: true }, ...old.items] })
return { previous }
},
cancelQueries() stops any refetch of the list already running, so an older answer cannot overwrite the new row. getQueryData() reads the first page as it is now: the snapshot, to put back if the add fails. setQueryData() writes the page with the new task on top, marked saving so the template shows Saving… beside it.
onMutate returns the snapshot as previous, and the mutation hands it to onError, the failure handler, as its third argument, onMutateResult.
onError: (_error, _title, onMutateResult) => {
queryClient.setQueryData(['tasks', 1], onMutateResult?.previous)
},
When the request fails, onError writes the snapshot back and the row disappears. When it succeeds, the refetch in onSettled replaces the temporary row with the server's.
Long lists: pages and a feed
Fifty tasks make five pages of ten, or one feed that grows at the end. Moving between pages should never blank the list.
The Pages button shows TaskPages.vue, which puts the page number, the ref page, into the key, so each page is a cache entry of its own.
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ['tasks', page],
queryFn: () => getTasks(page.value),
placeholderData: keepPreviousData,
})
Without placeholderData, clicking Next changes the key to ['tasks', 2], which has no data yet: isPending turns true and the list blanks to Loading… until page 2 arrives. keepPreviousData shows the previous page meanwhile, and isPlaceholderData tells the template it is old: the starter dims it, shows Loading page 2… and disables Next until the real page lands.
The Feed button shows the other view, TaskFeed.vue, which adds ten more tasks at the end instead of replacing the page. useInfiniteQuery() keeps every loaded page in one entry, as data.pages, and asks getNextPageParam for the next page number, or undefined when there is none. queryFn gets that number as pageParam, starting from initialPageParam.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['tasks', 'feed'],
queryFn: ({ pageParam }) => getTasks(pageParam),
initialPageParam: 1,
getNextPageParam: last => (last.page < last.totalPages ? last.page + 1 : undefined),
})
The Load more button calls fetchNextPage(), which loads one more page. isFetchingNextPage is true while it does, and hasNextPage turns false once the last page has loaded, so the button says No more tasks.
What goes wrong
The first mistake writes the optimistic row and never takes it back: it invalidates in onSuccess, which runs only on success, and has no onError.
onMutate: async (newTitle: string) => { /* … the snapshot and the new row … */ },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
When the server fails with status 500, the task stays in the list marked Saving…, under a message that says it was not saved. Nothing refetches after a failure, so the ghost row stays until something else refetches the page, such as a return to the browser tab. Restore the snapshot in onError, and invalidate in onSettled, which runs either way.
onError: (_error, _title, onMutateResult) => {
queryClient.setQueryData(['tasks', 1], onMutateResult?.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
The second mistake invalidates a key that matches nothing.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['task'] }),
The add succeeds, yet the new row keeps its Saving… label, the count stays at 50 tasks and the GET counter does not move. Keys match element by element, and 'task' is not 'tasks', so no entry was refetched. Nothing warns. Use the same key the queries use, ideally from one shared constant or function.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
What to remember
useMutation({ mutationFn })sends a change andmutate()starts it. Mutations do not retry unless you ask, because repeating a write can duplicate it.- After a write,
invalidateQueries({ queryKey })refetches every entry whose key starts with that key. A key that matches nothing refetches nothing, silently. - An optimistic update takes a snapshot in
onMutate, writes the expected result withsetQueryData(), restores the snapshot inonErrorand invalidates inonSettled. - Put the page number in the key, and use
placeholderData: keepPreviousDataso the old page stays while the next one loads. useInfiniteQuery()keeps every loaded page in one entry.getNextPageParamreturns the next page, orundefinedat the end.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In fakeServer.ts, set
failEveryto 3 and add three tasks. The first two stay. The third appears marked Saving…, disappears when the server answers 500, and Not saved: Request failed with 500 shows once the list has been refetched. - Click Next twice: each time the old page stays, dimmed, beside Loading page 2… or 3…, until the new one arrives. In TaskPages.vue delete the
placeholderDataline and click Next: the list blanks to Loading… and the buttons jump up. Click Previous: a page you have seen shows at once from the cache. - In TaskFeed.vue, import
useTemplateRefandwatchEffectfromvueanduseElementVisibilityfrom@vueuse/core. At the Try it 3 comment, addconst atEnd = useElementVisibility(useTemplateRef('more'))andwatchEffect(() => { if (atEnd.value && hasNextPage.value && !isFetchingNextPage.value) fetchNextPage() }). Click Feed, keep the preview on screen and scroll it down: whenever Load more is in view, ten more tasks arrive without a click, until the button says No more tasks.