This episode dissects data mutations with useMutation, query invalidation after a mutation, optimistic updates with rollback on failure, and paginated data with infinite scrolling using useInfiniteQuery.

Episode 4 covered reading data. But real apps also need to write: creating todos, updating profiles, deleting items. That's where useMutation comes in — the natural partner to useQuery that changes data on the server, then syncs the cache.
Episode 5 dissects useMutation, query invalidation, optimistic updates with rollback, and the paginated data and infinite scrolling patterns through useInfiniteQuery. These patterns appear in nearly every production application.
By the end of the episode, you'll be able to build UI that feels instant: press a button, data changes on screen right away, then gets quietly reconciled with the server's result.
useMutation is similar to useQuery, but for operations that change data — POST, PUT, PATCH, and DELETE. After success, the affected queries must be invalidated so the latest data is fetched:
import { useMutation, useQueryClient } from "@tanstack/react-query"
function TambahTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (judul) =>
fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ judul }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})
return <button onClick={() => mutation.mutate("Tugas baru")}>Tambah</button>
}mutationFn receives a variable — here judul. On success, onSuccess calls invalidateQueries({ queryKey: ["todos"] }), which marks the todos query stale and triggers a refetch.
Invalidation is always safe, but sometimes slow because it triggers another request. The alternative is writing to the cache directly with setQueryData so the UI changes instantly:
const mutation = useMutation({
mutationFn: tambahTodo,
onSuccess: (todoBaru) => {
queryClient.setQueryData(["todos"], (lama) => [...lama, todoBaru])
},
})setQueryData(["todos"], (lama) => [...lama, todoBaru]) adds the new item to the cached array without waiting for another request. For operations whose result is easy to predict, this pattern is faster; for uncertain results, stick with invalidation.
An optimistic update shows the mutation result immediately, then reverts it if the server rejects it. The key is saving the old data in onMutate as context and restoring it in onError:
const mutation = useMutation({
mutationFn: perbaruiTodo,
onMutate: async (todoBaru) => {
await queryClient.cancelQueries({ queryKey: ["todos"] })
const sebelumnya = queryClient.getQueryData(["todos"])
queryClient.setQueryData(["todos"], (lama) =>
lama.map((t) => (t.id === todoBaru.id ? todoBaru : t))
)
return { sebelumnya }
},
onError: (err, todoBaru, konteks) => {
queryClient.setQueryData(["todos"], konteks.sebelumnya)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})onMutate cancels any in-flight queries, saves the old data, then writes the optimistic version. If it fails, onError restores konteks.sebelumnya. Finally, onSettled runs the invalidation to guarantee consistency with the server.
Lists loaded incrementally use useInfiniteQuery with getNextPageParam to determine the cursor for the next page:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["proyek"],
queryFn: ({ pageParam }) => ambilProyek(pageParam),
initialPageParam: 1,
getNextPageParam: (halamanTerakhir) => halamanTerakhir.nextCursor ?? undefined,
})
return (
<div onScroll={handleScroll}>
{data.pages.flatMap((p) => p.items).map((item) => <Item key={item.id} item={item} />)}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
Muat lagi
</button>
</div>
)pageParam is injected into queryFn automatically, starting from initialPageParam: 1. getNextPageParam reads the cursor from the last page; when it returns undefined, hasNextPage becomes false and the button is disabled.
Warning
When writing an optimistic update, always cancel in-flight queries in onMutate. Otherwise, old data from a still-running fetch can overwrite your optimistic data.
Episode 5 completed the write side of TanStack Query: useMutation for changing data, invalidation and setQueryData for syncing the cache, optimistic updates with safe rollback, and useInfiniteQuery for endless lists.
Key takeaways:
In the next episode, episode 6, we'll discuss table core and basic rendering — columnHelper for defining columns, useReactTable with getCoreRowModel, rendering through flexRender, and the basic sorting, filtering, and pagination features. You'll build your first table with TanStack Table!