Domina las Mutaciones en React Query
Este artículo es una traducción del post "Mastering Mutations in React Query" publicado por Dominik en su blog TkDodo.eu
Hemos cubierto ya mucho terreno en lo que se refiere a las caracterísicas y conceptos que ofrece React Query. La mayoría es sobre la obtención de data, usando el hook useQuery
. Existe, aun así, una segunda parte integral al trabajar con data: la actualización.
Para este caso, React Query expone el hook useMutation
.
¿Qué son las mutaciones?
Hablando en general, las mutaciones son funciones que tienen un efecto secundario. Por ejemplo, mira el métido push
de un Array: tiene el efecto secundario de cambiar el array al que estás añadiendo un valor:
const myArray = [1];
myArray.push(2);
console.log(myArray); // [1, 2]
El opuesto, inmutable, sería concat
, que también puede añadir valores a arrays, pero devolverá el nuevo array, en lugar de manipular directamente el array original con el que trabajabas:
const myArray = [1];
const newArray = myArray.concat(2);
console.log(myArray); // [1]
console.log(newArray); // [1, 2]
Como su nombre indica, useMutation
también tiene una especie de efecto secundario. Como estamos en el contexto de la gestión de estado del servidor con React Query, las mutaciones describen una función que realiza un efecto secundario en el servidor. Crear un nuevo to-do
en tu base de datos sería una mutación. Loguear a un usuario sería también una mutación clásica, porque realiza el efecto secundario de crear un token para el usuario.
En algunos (pocos) aspectos, useMutation
es similar a useQuery
. En otros, muy diferente.
Similitudes con useQuery
useMutation
sigue el estado de una mutación, igual que useQuery
hace para las solicitudes. Te dará valores loading
, error
y status
para hacerte más sencillo mostrar a los usuarios qué está pasando.
…Y aquí acaban los parecidos: hasta React Query v4 (incluido) existían las callbacks onSuccess
, onError
y onSettled
en ambos hooks, pero esto ya no es así.
Diferencias con useQuery
useQuery
es declarativo.useMutation
es imperativo.
Eso significa que las solicitudes se ejecutan automáticamente. Defines las dependencias, pero React Query se encarga de ejecutar la solicitud inmediatamente, y también hace algunas actualizaciones en el background cuando lo estima necesario. Esto funciona muy bien para las solicitudes porque queremos mantener sincronizado lo que vemos en la pantalla con la data real en el backend.
Para las mutaciones, esto no funcionaría tan bien. Imagina que se creara un nuevo to-do cada vez que enfocas la ventana de tu navegador… Así que en lugar de ejecutar la mutación inmediatamente, React Query te da una función que puedes invocar cuando quieras hacer la mutación:
function AddComment({ id }) {
// esto no hace nada todavía
const addComment = useMutation({
mutationFn: (newComment) =>
axios.post(`/posts/${id}/comments`, newComment),
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
// 🟢 mutación invocada cuando se envía el form
addComment.mutate(
new FormData(event.currentTarget).get('comment')
)
}}
>
<textarea name="comment" />
<button type="submit">Comment</button>
</form>
)
}
Otra diferencia es que las mutaciones no comparten estado como hace useQuery
. Puedes llamar a useQuery
varias veces en componentes distintos y obtendrás siempre el mismo resultado desde caché. Pero esto no funcionará para las mutaciones.
- Actualización: Empezando con la v5, puedes usar el hook
useMutationState
para compartir estado de mutación entre componentes.
Enlazando mutaciones con solicitudes
Las mutaciones, por diseño, no están emparejadas directamente con solicitudes. Una mutación que da un like a un artículo en un blog no tiene ningún enlace con la solicitud que obtiene ese artículo. Para que eso funcione necesitarías algún tipo de esquema interno, algo que React Query no tiene.
Para que el efecto de una mutación se refleje en nuestras solicitudes, React Query ofrece dos sistemas:
Invalidación
Esta es conceptualmente la manera más sencilla de mantener tu pantalla actualizada. Recuerda que solo estamos mostrando una captura de la data del servidor en un instante concreto. React Query intenta mantenerse al día, por supuesto, pero si cambias el estado del servidor intencionalmente con una mutación, este es el momento ideal para avisarle de que alguna data en caché es ahora inválida.
React Query entonces irá y hará una re-solicitud de esa data si está en uso actualmente, y tu pantalla se actualizará automáticamente cuando la solictud termine. Lo único que tienes que hacer es decirle a la librería qué solicitudes invalidar:
const useAddComment = (id) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newComment) =>
axios.post(`/posts/${id}/comments`, newComment),
onSuccess: () => {
// 🟢 re-solicitar los comentarios del post
queryClient.invalidateQueries({
queryKey: ['posts', id, 'comments']
})
},
})
}
La invalidación de solicitud es bastante inteligente. Como todos los filtros de solicitudes, usa búsqueda aproximada (fuzzy) en la queryKey
de la solicitud. Si tienes múltiples keys para tu lista de comentarios, se invalidarán todas. Eso sí, solo se re-solicitarán las que estén actualmente actvas. El resto se marcará como obsoleta (stale), lo que causará su re-solicitud la próxima vez que se usen.
Como ejemplo, imagina que tenemos la opción de ordenar los comentarios, y cuando se añadió el nuevo comentario, teníamos dos solicitudes en nuestro caché:
['posts', 5, 'comments', { sortBy: ['date', 'asc'] }
['posts', 5, 'comments', { sortBy: ['author', 'desc'] }
Como solo estamos mostrando una de ellas en pantalla, invalidateQueries
re-solicitará esa y marcará la otra como “obsoleta”.
Actualizaciones directas
Algunas veces preferimos no re-solicitar data, especialmente si la mutación ya devuelve todo lo que neceistas. Si tienes una mutación que actualiza el título de un artículo, y el backend devuelve el artículo completo como respuesta, puedes actualizar el caché directamente con setQueryData
:
const useUpdateTitle = (id) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTitle) =>
axios
.patch(`/posts/${id}`, { title: newTitle })
.then((response) => response.data),
// la respuesta se pasa a onSuccess
onSuccess: (newPost) => {
// 🟢 actualizar la vista detalle directamente
queryClient.setQueryData(['posts', id], newPost)
},
})
}
Poner la data directamente en el caché con setqueryData
actuará como si esta data se hubiera devuelto desde el backend, lo que significa que todos los componentes usando esa solicitud se re-renderizarán correctamente.
Tienes más ejemplos de actualizaciones directas y la combinación de los dos enfoques en la parte 8, Claves eficaces.
El autor recomienda usar invalidación en la mayoría de casos. Por supuesto esto depende del caso de uso, pero para que las actualizaciones directas funcionen bien, necesitas más código en el frontend, y hasta cierto punto lógica duplicada desde el backend. Las listas ordenadas son, por ejemplo, bastante difíciles de ordenar directamente, ya que la posición de una entrada podría haber cambiado tras la actualización. Invalidar la lista completa es un enfoque más seguro.
Actualizaciones optimistas
Las actualizaciones optimistas son uno de los puntos fuertes para usar las mutaciones de React Query. El caché de useQurey
nos da data al instante al cambiar entre solicitudes, especialmente combinado con prefetching. Toda la UI parece muy rápida por ello, así que ¿por qué no tener las mismas ventajas para las mutaciones?
La mayor parte del tiempo tenemos la seguridad de que una mutación funcionará. ¿Por qué debería esperar el usuario unos segundos hasta que el backend nos dé el ok para mostrar el resultado en pantalla? La idea de las actualizaciones optimistas es imitar el éxito de una mutación incluso antes de mandarla al servidor. Cuando este nos devuelva una respuesta de éxito, todo lo que hay que hacer es invalidar la vista para volver a ver data real. Si la llamada falla, devolvemos la UI al estado anterior a la mutación.
Esto funciona muy bien para mutaciones pequeñas donde el usuario espera un feedback instantáneo. No hay nada peor que tener un botón tipo switch que haga una solicitud, y que no se mueva hasta que esta se haya completado. Los usuarios acabarán clicando dos o tres veces el mismo botón, y la UI parecerá lenta.
¿Ejemplos?
El autor ha decidido no mostar un ejemplo extra. La documentación oficial ya cubre este tema bastante bien, y también hay un ejemplo en codesandbox con Typescript.
También opina que las actualizaciones optimistas se usan en exceso. No todas las mutaciones necesitan ser optimistas. Deberías tener mucha seguridad en que realmente casi nunca falla, porque la experiencia de usuario al volver atrás la UI no es muy buena: Imagina un formulario en una modal que se cierra cuando lo envías, o una redirección desde una vista detalle a una vista de lista tras una actualización. Si se hacen de forma prematura, es difícil deshacerlas.
Además, asegúrate de que el feedback instantáneo es realmente necesario (como en el botón del ejemplo anterior). El código para hacer funcionar actualizaciones optimistas no es trivial, especialemnte en comparación con mutaciones normales. Cuando replicas el resultado tienes que imitar lo que haría el backend, lo que puede ser tan sencillo como cambiar un booleano o añadir un item a un array, pero puede complicarse rápido:
- Si el to-do que añades necesita una
id
, ¿de dónde la sacas? - Si la lista que se está viendo está ordenada, ¿meterás la nueva entrada en la posición correcta?
- ¿Qué pasa si otro usuario ha añadido algo más en ese intervalo? ¿Se moverá la entrada que hemos añadido a la lista cuando se haga una re-solicitud?
Todos estos casos pueden hacer peor la experiencia de usuario en algunas situaciones, donde habría valido con deshabilitar el botón y mostrar una animación de carga mientras la mutación está en marcha. Como siempre, deberías elegir la herramienta corecta para cada tarea.
Problemas habituales
Para terminar, veamos algunas cosas que es bueno recordar al trabajar con mutaciones y que no son tan obvias al principio:
Promesas pendientes
React Query hace un await
con las promesas que se devuelven desde la callback de una mutación, y sucede que invalidateQueries
devuelve una Promesa. Si quieres que tu mutación esté en estado loading
mientras se actualizan las solicitudes relacionadas, tienes que devolver en tu callback el resultado de invalidateQueries
:
{
// 🚀 esperará a la invalidación para terminar
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ['posts', id, 'comments'],
})
}
}
{
// 🚀 sin mirar atrás: no esperará
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['posts', id, 'comments']
})
}
}
mutate
o mutateAsync
El hook useMutation
devuelve dos funciones: mutate
y mutateAsync
. ¿Cuál es la diferencia, y cuándo deberías usar cada una?
mutate
no devuelve nada, mientras que mutateAsync
devuelve una Promesa que contiene el resultado de la mutación. Así que podrías tener la tentanción de usar mutateAsync
cuando necesitas acceder a la respuesta de una mutación, pero te recomendaría usar mutate
casi siempre.
Siempre puedes acceder la data
o el error
a través de las callbacks de la mutación, y no tienes que preocuparte de gestionar los errores: como mutateAsync
te da control sobre la Promesa, también tienes que capturar los errores manualmente, o puede que te salte una “unhandled promise rejection”.
const onSubmit = () => {
// 🟢 acceder a la respuesta desde onSuccess
myMutation.mutate(someData, {
onSuccess: (data) => history.push(data.url),
})
}
const onSubmit = async () => {
// 🚨 funciona, pero no se gestionan los errores
const data = await myMutation.mutateAsync(someData)
history.push(data.url)
}
const onSubmit = async () => {
// 🟡 esto está bien, pero no puede ser más verboso...
try {
const data = await myMutation.mutateAsync(someData)
history.push(data.url)
} catch (error) {
// no hacer nada
}
}
Gestionar los errores no es necesario con mutate
, porque React Query captura (y descarta) el error por ti internamente. Literalmente está implementado con: mutateAsync().catch(noop)
😎.
Las únicas situaciones donde es mejor usar mutateAsync
es cuando realmente necesitas una Promesa por el hecho de que sea una promesa. Esto puede ser necesario si quieres lanzar múltiples Promesas de forma concurrente y esperar a que todas terminen, o si tienes mutaciones dependientes y no quieres caer en un “callback hell”.
Las mutaciones solo aceptan un argumento en variables
Como el último argumento de mutate
es el objeto de opciones, useMutation
actualmente solo acepta un argumento para variables. Esto es por supuesto una limitación, pero se puede salvar fácilmente usando un objeto:
// 🔴 sintaxis inválida, NO funcionará
const mutation = useMutation({
mutationFn: (title, body) => updateTodo(title, body),
})
mutation.mutate('hello', 'world')
// 🟢 usa un objeto para múltiples variables
const mutation = useMutation({
mutationFn: ({ title, body }) => updateTodo(title, body),
})
mutation.mutate({ title: 'hello', body: 'world' })
Para leer más sobre por qué esto es necesario, puedes mirar esta discusión.
Algunas callbacks no se ejecutarán
Puedes tener callbacks tanto en useMutation
como en la misma mutate
. Es importante saber que las callbacks en useMutation
se ejecutan antes que las de mutate
. Además, puede que las callbacks en mutate
no se ejecuten si el componente se desmonta antes de que la mutación haya terminado.
Por eso pienso que es una buena práctica separar responsabilidades en tus callbacks:
- Haz las cosas que sean absolutamente necesarias y relacionadas con la lógica (como invalidar solicitudes) en las callbacks de
useMutation
. - Haz cosas relacioandas con la UI como redirecciones o mostrar notificaciones en las callbacks de
mutate
. Si el usuario se marcha de la pantalla actual antes de la que la mutación termine, estas no se llamarán.
Esta separación funciona todavía mejor cuando el useMutation
viene de un hook personalizado, ya que esto mantendrá la lógica de solicitud en el hook, mientras las acciones de UI están en la UI. Esto también hace el hook más reutilizable, porque puede que la interacción con la UI varíe según el caso, pero la lógica de invalidación se mantenga constante:
const useUpdateTodo = () =>
useMutation({
mutationFn: updateTodo,
// 🟢 invalidar la lista siempre
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['todos', 'list']
})
},
})
// en el componente:
const updateTodo = useUpdateTodo()
updateTodo.mutate(
{ title: 'newTitle' },
// 🟢 solo redirigir si seguimos en la página detalle
// cuando la mutación termine
{ onSuccess: () => history.push('/todos') }
)
La serie completa
Este post es parte de la serie React-Query por Tkdodo que he traducido desde su blog. Mira todos los artículos:
- Consejos prácticos sobre React Query
- Transformación de data en React Query
- Optimización del renderizado en React Query
- Comprobar el estado en React Query
- Tests en React Query
- React Query y TypeScript
- Usar Websockets con React Query
- Claves eficaces en React Query
- Data inicial y de ejemplo en React Query
- React Query como un Gestor de Estado
- Gestión de Errores en React Query
- Domina las Mutaciones en React Query
- React Query offline
- Formularios en React Query
No hay sección de comentarios, pero me encantaría escuchar tu opinión: escríbeme en Twitter y cuéntame!