Mutaciones y actualizaciones optimistas
Receta
Usa useSWRMutation para mutaciones remotas (POST, PUT, DELETE) y mutate para actualizaciones locales de caché. Combina ambos para patrones de UI optimista que se actualizan al instante y se reconcilian con el servidor.
"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 ? "Creando..." : "Crear publicación"}
</button>
</form>
);
}Ejemplo funcional
"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;
// Actualización optimista
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>
);
}Profundización
Cómo funciona
useSWRMutationestá diseñado para mutaciones que no deben ejecutarse automáticamente. Devuelve una funcióntriggerque llamas manualmente.- La función
triggerrecibe unargque se pasa a tu función de mutación como{ arg }. - mutate vinculado (
mutatedeuseSWR) está limitado a la clave del hook. Actualiza la caché local para esa clave específica. - mutate global (de
useSWRConfig) puede actualizar cualquier clave de caché desde cualquier parte de tu app. optimisticDataactualiza la caché de inmediato antes de que la mutación asincrónica se resuelva.rollbackOnError: truerevierte la actualización optimista si la mutación lanza un error.revalidate: falseomite volver a obtener datos tras la mutación cuando confías en la actualización local.
Variaciones
mutate global para invalidar claves relacionadas:
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 }),
});
// Revalidar múltiples claves
mutate(`/api/posts/${postId}`);
mutate(`/api/posts/${postId}/comments`);
};
}Mutación con datos devueltos:
const { trigger } = useSWRMutation("/api/posts", createPost, {
onSuccess(data) {
// data es el valor de retorno de createPost
console.log("Creado:", data.id);
},
});Poblar la caché tras la mutación:
const { trigger } = useSWRMutation("/api/posts", createPost, {
populateCache: (newPost, currentPosts) => [...(currentPosts ?? []), newPost],
revalidate: false,
});Notas de TypeScript
useSWRMutation<Data, Error, Key, Arg>acepta cuatro genéricos para seguridad de tipos completa.- La firma de la función de mutación es
(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" }) — totalmente tipadoErrores comunes
useSWRMutationyuseSWRusan la misma caché. Si ambos usan la misma clave, comparten datos. Esto suele ser lo que quieres, pero ten en cuenta que eltriggerdeuseSWRMutationpuede sobrescribir los datos en caché deuseSWR.optimisticDatadebe ser el estado nuevo completo, no una actualización parcial. Si tienes una lista, debes devolver la lista actualizada completa.- Si olvidas
rollbackOnError: true, una mutación fallida deja datos optimistas obsoletos en la caché. isMutatingpermanece entruehasta que la promesa detriggerse resuelve. Si navegas fuera antes de que se resuelva, no hay limpieza a menos que el componente se desmonte.- Llamar a
mutate()sin argumentos revalida la clave volviendo a obtener datos. Llamar amutate(newData)establece los datos sin revalidación.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| useSWRMutation | Diseñado para ello, se integra con la caché | Requiere una importación separada |
| mutate vinculado + fetch | Más simple para actualizaciones básicas de caché | Manejo manual de errores |
| React Query useMutation | Más reintentos y hooks de ciclo de vida integrados | Biblioteca distinta |
| Server Actions (Next.js) | Sin JS en el cliente, nativo de formularios | Sin UI optimista sin trabajo adicional |
FAQs
¿Cuál es la diferencia entre useSWRMutation y la función mutate de useSWR?
useSWRMutationes para mutaciones remotas (POST, PUT, DELETE) activadas manualmente mediantetrigger.mutatedeuseSWR(vinculado) actualiza la caché local para una clave específica.mutateglobal deuseSWRConfigpuede actualizar cualquier clave de caché desde cualquier lugar.
¿Cómo pasa la función trigger argumentos a la función de mutación?
El argumento pasado a trigger(arg) está disponible en la función de mutación como { 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" });¿Qué hace optimisticData y por qué debe ser el estado nuevo completo?
optimisticData actualiza la caché de inmediato antes de que la mutación asincrónica se resuelva. Debe ser el estado nuevo completo (p. ej., la lista actualizada entera), no una actualización parcial, porque SWR reemplaza toda la entrada de caché con ese valor.
Trampa: ¿Qué ocurre si olvido rollbackOnError: true con actualizaciones optimistas?
Si la mutación falla, los datos optimistas obsoletos permanecen en la caché de forma permanente. La UI mostrará datos que no coinciden con el estado del servidor. Establece siempre rollbackOnError: true cuando uses optimisticData.
¿Cómo puedo revalidar varias claves de caché relacionadas tras una mutación?
Usa mutate global de useSWRConfig:
const { mutate } = useSWRConfig();
mutate(`/api/posts/${postId}`);
mutate(`/api/posts/${postId}/comments`);¿Qué hace la opción populateCache en useSWRMutation?
Te permite actualizar la caché directamente con el valor de retorno de la mutación sin una revalidación fetch:
const { trigger } = useSWRMutation("/api/posts", createPost, {
populateCache: (newPost, currentPosts) => [...(currentPosts ?? []), newPost],
revalidate: false,
});Trampa: ¿useSWRMutation y useSWR comparten la misma caché cuando usan la misma clave?
Sí. Si ambos usan la misma clave, comparten los datos en caché. El trigger de useSWRMutation puede sobrescribir los datos que useSWR está mostrando. Esto suele ser intencional, pero puede sorprender si no lo esperas.
¿Qué ocurre con isMutating si navego fuera antes de que la mutación se resuelva?
isMutating permanece en true hasta que la promesa de trigger se resuelve. Si el componente se desmonta antes de la resolución, no hay limpieza. Ten cuidado al navegar durante mutaciones en curso.
¿Cómo tipar useSWRMutation con genéricos completos en 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" }) está totalmente tipado¿Cuál es la firma TypeScript de una función de mutación de useSWRMutation?
La firma es (key: Key, options: { arg: Arg }) => Promise<Data>. Los cuatro genéricos en useSWRMutation<Data, Error, Key, Arg> controlan los tipos del dato de retorno, el error, la clave y el argumento pasado a trigger.
¿Cuál es la diferencia entre llamar a mutate() sin argumentos frente a mutate(newData)?
mutate()sin argumentos revalida volviendo a obtener datos del servidor.mutate(newData)establece los datos de la caché directamente sin volver a obtener.