This episode teaches write operations with useMutation: the isPending and isError statuses, the onSuccess and onError callbacks, and the two cache synchronization patterns, invalidateQueries and setQueryData, after a successful mutation.

Reading data is only half the journey. The other half is writing data — adding a todo, updating a profile, deleting an item. In the manual pattern, write operations are also messy: manual loading status, errors that are easy to miss, and a cache that is never updated, so the page shows stale data.
Episode 6 covers useMutation for write operations, then two cache synchronization patterns that keep the UI always consistent with the server: invalidateQueries and setQueryData.
useMutation is almost the same as useQuery, but it doesn't run automatically on mount — it waits to be called via the mutate or mutateAsync method:
import { useMutation, useQueryClient } from "@tanstack/react-query"
async function createTodo(title) {
const res = await fetch("https://jsonplaceholder.typicode.com/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, completed: false }),
})
if (!res.ok) throw new Error("Gagal membuat todo")
return res.json()
}
function TodoForm() {
const mutation = useMutation({ mutationFn: createTodo })
return (
<form
onSubmit={(event) => {
event.preventDefault()
const title = event.target.title.value
mutation.mutate(title)
}}
>
<input name="title" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Menyimpan..." : "Simpan"}
</button>
{mutation.isError && <p>Gagal: {mutation.error.message}</p>}
</form>
)
}mutation.mutate(title) triggers createTodo with the title argument. The isPending status indicates the operation is running and is suitable for disabling the button; isError and error show the failure. useMutation does not automatically write to the cache — that's your job next.
You can react to success or failure through callbacks defined when declaring the mutation:
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: (data) => {
console.log("Todo baru:", data)
},
onError: (error) => {
console.error("Mutasi gagal:", error)
},
})onSuccess receives the mutationFn result as its first argument. In v5, this callback is only available on useMutation, not on useQuery — an important change to remember if you're migrating from v4.
After a successful mutation, the data in the cache may no longer reflect the server. The most common way to update is invalidation: mark specific queries as stale so they are automatically refetched on the next opportunity.
import { useMutation, useQueryClient } from "@tanstack/react-query"
function TodoForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})
return <Form /> // markup di episode sebelumnya
}queryClient.invalidateQueries({ queryKey: ["todos"] }) marks every key with the ["todos"] prefix as stale. Because keys are hierarchical, the ["todos", 5] and ["todos", "detail", 5] queries are also refetched if they are active. invalidateQueries is the recommended default pattern because it is the simplest and hardest to get wrong.
If a mutation only touches one item, narrow the scope so you don't shoot too many queries:
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos", id] })
}queryKey: ["todos", id] targets a specific item, not the whole list. invalidateQueries accepts a partial key expression — choosing the scope is part of the art of performance management that we will explore in episode 13.
For a more responsive UI, you can write directly to the cache with setQueryData without waiting for a refetch. This is the pattern for data whose shape is already known:
onSuccess: (newTodo) => {
queryClient.setQueryData(["todos"], (old) => [...(old ?? []), newTodo])
}queryClient.setQueryData(["todos"], ...) merges the new todo into the existing array. setQueryData accepts an updater function like useState — the old argument is the current cache data, and the return value becomes the new data.
setQueryData for speed, then invalidateQueries to guarantee final consistency.Warning
setQueryData writes the cache without validating the data shape. Make sure the data shape you write is the same as what other components expect, otherwise rendering will throw strange errors that are hard to trace.
Episode 6 completed the read-write cycle: reading with useQuery, writing with useMutation, then syncing the cache with invalidateQueries for automatic refetch or setQueryData for direct updates. You can now build applications whose data is always consistent.
Key takeaways:
useMutation is triggered via mutate or mutateAsync, not automatically.isPending indicates an operation is running; isError for failure.onSuccess and onError are available on useMutation, not useQuery.invalidateQueries marks queries as stale so they are automatically refetched.setQueryData writes directly to the cache for instant updates.In the next episode, episode 7, we will discuss staleness and refetching — the difference between staleTime and gcTime, and the refetchOnWindowFocus, refetchOnReconnect, refetchInterval, and manual refetch options. You will understand when data is refetched and how to control it.