Mutations and Optimistic Updates
Recipe
Use useSWRMutation for remote mutations (POST, PUT, DELETE) and mutate for local cache updates. Combine both for optimistic UI patterns that update instantly and reconcile with the server.
"use client";
import useSWRMutation from "swr/mutation";
async function createPost(url: string, { arg }: { arg: { title: string; body: string } }) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
return res.json();
}
function NewPostForm() {
const { trigger, isMutating } = useSWRMutation("/api/posts", createPost);
const handleSubmit = async (formData: FormData) => {
await trigger({
title: formData.get("title") as string,
body: formData.get("body") as string,
});
};
return (
<form action={handleSubmit}>
<input name="title" required />
<textarea name="body" required />
<button disabled={isMutating}>
{isMutating ? "Creating..." : "Create Post"}
</button>
</form>
);
}Working Example
"use client";
import useSWR, { useSWRConfig } from "swr";
import useSWRMutation from "swr/mutation";
interface Todo {
id: number;
text: string;
done: boolean;
}
const fetcher = (url: string): Promise<Todo[]> => fetch(url).then((r) => r.json());
async function toggleTodo(url: string, { arg }: { arg: { id: number; done: boolean } }) {
return fetch(`${url}/${arg.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done: arg.done }),
}).then((r) => r.json());
}
export default function TodoList() {
const { data: todos, mutate } = useSWR<Todo[]>("/api/todos", fetcher);
const { trigger } = useSWRMutation("/api/todos", toggleTodo);
const handleToggle = async (todo: Todo) => {
const newDone = !todo.done;
// Optimistic update
await mutate(
async (current) => {
await trigger({ id: todo.id, done: newDone });
return current?.map((t) => (t.id === todo.id ? { ...t, done: newDone } : t));
},
{
optimisticData: todos?.map((t) =>
t.id === todo.id ? { ...t, done: newDone } : t
),
rollbackOnError: true,
revalidate: false,
}
);
};
return (
<ul>
{todos?.map((todo) => (
<li key={todo.id} onClick={() => handleToggle(todo)}>
<span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
</span>
</li>
))}
</ul>
);
}Deep Dive
How It Works
useSWRMutationis designed for mutations that should not run automatically. It returns atriggerfunction you call manually.- The
triggerfunction receives anargthat gets passed to your mutation function as{ arg }. - Bound mutate (
mutatefromuseSWR) is scoped to the hook's key. It updates the local cache for that specific key. - Global mutate (from
useSWRConfig) can update any cache key from anywhere in your app. optimisticDataimmediately updates the cache before the async mutation resolves.rollbackOnError: truereverts the optimistic update if the mutation throws.revalidate: falseskips re-fetching after mutation when you trust the local update.
Variations
Global mutate to invalidate related keys:
import { useSWRConfig } from "swr";
function AddComment({ postId }: { postId: string }) {
const { mutate } = useSWRConfig();
const handleAdd = async (text: string) => {
await fetch(`/api/posts/${postId}/comments`, {
method: "POST",
body: JSON.stringify({ text }),
});
// Revalidate multiple keys
mutate(`/api/posts/${postId}`);
mutate(`/api/posts/${postId}/comments`);
};
}Mutation with returned data:
const { trigger } = useSWRMutation("/api/posts", createPost, {
onSuccess(data) {
// data is the return value from createPost
console.log("Created:", data.id);
},
});Populate cache after mutation:
const { trigger } = useSWRMutation("/api/posts", createPost, {
populateCache: (newPost, currentPosts) => [...(currentPosts ?? []), newPost],
revalidate: false,
});TypeScript Notes
useSWRMutation<Data, Error, Key, Arg>takes four generics for full type safety.- The mutation function signature is
(key: Key, options: { arg: Arg }) => Promise<Data>.
const { trigger } = useSWRMutation<Todo, Error, string, { text: string }>(
"/api/todos",
async (url, { arg }) => {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(arg),
});
return res.json();
}
);
// trigger({ text: "Buy milk" }) — fully typedGotchas
useSWRMutationanduseSWRuse the same cache. If both use the same key, they share data. This is usually what you want, but be aware thatuseSWRMutation's trigger can overwriteuseSWR's cached data.optimisticDatamust be the full new state, not a partial update. If you have a list, you need to return the entire updated list.- If you forget
rollbackOnError: true, a failed mutation leaves stale optimistic data in the cache. isMutatingstaystrueuntil the trigger promise resolves. If you navigate away before it resolves, there is no cleanup unless the component unmounts.- Calling
mutate()with no arguments revalidates the key by re-fetching. Callingmutate(newData)sets data without revalidation.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| useSWRMutation | Purpose-built, integrates with cache | Requires separate import |
| Bound mutate + fetch | Simpler for basic cache updates | Manual error handling |
| React Query useMutation | More built-in retry and lifecycle hooks | Different library |
| Server Actions (Next.js) | No client JS, form-native | No optimistic UI without extra work |
FAQs
What is the difference between useSWRMutation and the mutate function from useSWR?
useSWRMutationis for remote mutations (POST, PUT, DELETE) triggered manually viatrigger.mutatefromuseSWR(bound) updates the local cache for a specific key.- Global
mutatefromuseSWRConfigcan update any cache key from anywhere.
How does the trigger function pass arguments to the mutation function?
The argument passed to trigger(arg) is available in the mutation function as { arg }:
async function createPost(url: string, { arg }: { arg: { title: string } }) {
return fetch(url, {
method: "POST",
body: JSON.stringify(arg),
}).then((r) => r.json());
}
const { trigger } = useSWRMutation("/api/posts", createPost);
await trigger({ title: "Hello" });What does optimisticData do and why must it be the full new state?
optimisticData immediately updates the cache before the async mutation resolves. It must be the complete new state (e.g., the entire updated list), not a partial update, because SWR replaces the entire cache entry with it.
Gotcha: What happens if I forget rollbackOnError: true with optimistic updates?
If the mutation fails, the stale optimistic data remains in the cache permanently. The UI will show data that does not match the server state. Always set rollbackOnError: true when using optimisticData.
How can I revalidate multiple related cache keys after a mutation?
Use the global mutate from useSWRConfig:
const { mutate } = useSWRConfig();
mutate(`/api/posts/${postId}`);
mutate(`/api/posts/${postId}/comments`);What does the populateCache option do in useSWRMutation?
It lets you update the cache directly with the mutation's return value without a revalidation fetch:
const { trigger } = useSWRMutation("/api/posts", createPost, {
populateCache: (newPost, currentPosts) => [...(currentPosts ?? []), newPost],
revalidate: false,
});Gotcha: Do useSWRMutation and useSWR share the same cache when using the same key?
Yes. If both use the same key, they share cached data. The trigger from useSWRMutation can overwrite data that useSWR is displaying. This is usually intended but can cause surprises if unexpected.
What happens to isMutating if I navigate away before the mutation resolves?
isMutating stays true until the trigger promise resolves. If the component unmounts before resolution, there is no cleanup. Be cautious with navigation during in-flight mutations.
How do I type useSWRMutation with full generics in TypeScript?
const { trigger } = useSWRMutation<Todo, Error, string, { text: string }>(
"/api/todos",
async (url, { arg }) => {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(arg),
});
return res.json();
}
);
// trigger({ text: "Buy milk" }) is fully typedWhat is the TypeScript signature for a useSWRMutation mutation function?
The signature is (key: Key, options: { arg: Arg }) => Promise<Data>. The four generics on useSWRMutation<Data, Error, Key, Arg> control the types for return data, error, key, and the argument passed to trigger.
What is the difference between calling mutate() with no arguments vs mutate(newData)?
mutate()with no arguments revalidates by re-fetching from the server.mutate(newData)sets the cache data directly without re-fetching.