Todo lo que sé de SWR
Conceptos Básicos
Introducción
En este artículo muy largo vamos a ver como usar esta librería para trabajar con data remota, como la que traemos de un API, sacándo el máximo provecho a la librería.
Vamos a ir desde un uso básico de SWR donde vamos a aprender a
- Hacer una petición a nuestro API
- Manejar estados de carga
- Manejar errores
Hasta un uso más avanzado, al final vamos a poder:
- Reusar requests al API
- Reportar errores y reintentar requests
- Compartir la lógica para hacer requests
- Aprovechar data anterior para cargar más rápido
- Mantener la UI actualizada con el API
- Soportar paginación normal e infinita
- Precargar data antes de que se necesite
- User React Suspense para data-fetching
- Conectarse con WebSockets para actualizarse en Real-Time
- Implementar actualizaciones optimistas de la UI
Antes de empezar, es necesario saber React y Hooks, y como trabajar con data asíncrona. Si entendés que hace este código, o mejor, escribiste código similar, estás listo.
import React from "react";
function Pokemon() {
const [data, setData] = React.useState(undefined);
const [error, setError] = React.useState(undefined);
React.useEffect(
function getPokemon() {
fetch("https://pokeapi.co/api/v2/pokemon/")
.then((res) => res.json())
.then((data) => setData(data))
.catch((error) => setError(error));
},
[setData, setError]
);
if (error) {
return <p>Something failed: {error.message}</p>;
}
if (!data) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
}
Instalando SWR
Para poder empezar a usar SWR, lo primero que tenemos que hacer es instalarlo en nuestro proyecto.
Para esto, podemos usar Yarn o npm, el que ustedes prefieran, en mi caso voy a usar Yarn.
$ yarn add swr
Con esto tenemos SWR instalado, vamos a verificarlo, podemos revisar en nuestro package.json
que esté SWR entre nuestras dependencias.
Además vamos a ver que funciona en nuestro código agregando un simple console.log
.
import useSWR from "swr";
console.log(useSWR);
Si todo va bien, nos tiene que mostrar una función en la consola.
Con esto sabemos que SWR se instaló correctamente y estamos listos para empezar a usarlo.
Request básico
Vamos a hacer nuestra primera petición o request con SWR.
Para esto tenemos que entender que SWR no viene con la funcionalidad de hacer un request HTTP directamente. Para poder hacer eso tenemos que pasarle una función que se encargue de hacer el request a nuestro API.
Para este proyecto vamos a utilizar el Pokeapi.
Vamos a hacer el primer request. Primero vamos a llamar a useSWR
dentro de nuestro componente pasándole la URL del API como primero argumento.
useSWR("https://pokeapi.co/api/v2/pokemon/pikachu");
Este argumento es llamado key
y se usa como identificador de nuestro request, este es usado por SWR para guardar en una cache interna la información que obtuvimos del API, esto le permite a SWR re-usar esa información mientras la revalida por detrás, aunque la primera vez que usemos una key siempre va a hacer el request sin tener información en cache.
SWR va a pasarlo a una función interna llamada fetcher
que va a usarlo como URL para hacer nuestro request, esta función es posible personalizarla y en la mayoría de casos vamos a querer hacerlo. Veamos como.
La función fetcher
que viene por defecto en SWR tiene este formato:
function fetcher(key) {
return fetch(key).then(res => res.json())
}
Solo hace el request usando nuestra key
como URL y lee el resultado como JSON.
Creemos nuestra función fetcher
propia, a diferencia de la por defecto vamos a recibir parte de la URL y vamos a armar la URL entera dentro de fetcher
.
function fetcher(path) {
const url = `https://pokeapi.co/api/v2/${path}/`;
return fetch(url).then((res) => res.json());
}
Ahora podemos actualizar SWR para solo pasar el path y además pasar el fetcher como segundo argumento.
useSWR("pokemon/pikachu", fetcher);
Ahora que tenemos ambos listos podemos acceder a la data
que devuelve SWR. Por ahora vamos a mostrar el nombre del Pokémon que hicimos fetch en nuestra aplicación.
const { data } = useSWR("pokemon", fetcher);
return (
<div>
{data.results.map((pokemon) => (
<h2>Hello {pokemon.name}</h2>
))}
</div>
);
Cuando revisemos cómo funciona la aplicación vamos a obtener el error undefined is not an object
, eso es porque data
es inicialmente undefined
y falla al intentar acceder a la propiedad results.
Para evitar este problema de forma rápida podemos cambiarlo de esta forma.
const { data } = useSWR("pokemon", fetcher);
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Con esto ya deberíamos ver la lista de Pokémon en la pantalla.
Manejando el estado de carga
Sabemos que data
es inicialmente undefined
, esto pasa cuando nuestra key
es usada por primera vez por lo que la cache de SWR todavía no tiene datos que mostrar, esto nos permite usar este undefined
para detectar si nuestro componente está cargando o no y con base a esto mostrar un estado de carga.
const { data } = useSWR("pokemon", fetcher);
if (!data) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Con esto podemos mostrar algo diferente dependiendo del estado.
Si no ven el mensaje de carga es posible que el request haya terminado muy rápido, en ese caso podemos agregar un setTimeout
de un segundo antes de hacer el request para que este tarde más.
function fetcher(path) {
const url = `https://pokeapi.co/api/v2/${path}/`;
return new Promise((resolve, reject) =>
setTimeout(() => {
fetch(url)
.then((res) => res.json())
.then(resolve, reject);
}, 1000)
);
}
Una vez verifiquen que su estado de carga funciona pueden volver a dejar el fetcher como estaba.
Algo que puede pasar, especialmente en conexiones lentas, es que un request tarde demasiado en completarse, aunque podemos seguir mostrando el mensaje de carga el usuario puede llegar a creer que algo falló y la aplicación no se actualizó, para esto SWR nos permite pasarle una función que este va a ejecutar si nuestro request tarda mucho.
Esta opción la pasamos en un tercer argumento de SWR como propiedad de un objeto.
const [isLoadingSlow, setIsLoadingSlow] = React.useState(false);
const { data } = useSWR("pokemon", fetcher, {
onLoadingSlow(key, config) {
setIsLoadingSlow(true);
},
});
if (!data && !isLoadingSlow) {
return <p>Loading Pokémon...</p>;
}
if (!data && isLoadingSlow) {
return (
<p>
Our server is slower than usual, thanks for your patient while we load the
Pokémon
</p>
);
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
De esta forma podemos tener diferentes mensajes o interfaces de carga dependiendo de si tardó mucho o no. Incluso podríamos decidir no mostrar nada hasta que isLoadingSlow
sea true
.
const [isLoadingSlow, setIsLoadingSlow] = React.useState(false);
const { data } = useSWR("pokemon", fetcher, {
onLoadingSlow(key, config) {
setIsLoadingSlow(true);
},
});
React.useEffect(() => {
if (data && isLoadingSlow) {
const timer = setTimeout(setIsLoadingSlow, 3000, false);
return () => clearTimeout(timer);
}
}, [data, isLoadingSlow]);
if (!data && !isLoadingSlow) {
return null;
}
if (isLoadingSlow) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Con esto si el request se completa lo suficientemente rápido el usuario no ve una interfaz de carga por unos milisegundos para que rápidamente cargue, en vez de eso esperamos un poco y usando un efecto simulamos unos segundos más de carga para que tampoco se vaya muy rápido. Esto último parece raro pero ayuda a dar una mejor experiencia al usuario.
¿Cuánto tiempo va a esperar SWR antes de ejecutar onLoadingSlow
? Por defecto es espera tres segundos, pero esto podemos cambiarlo usando la opción loadingTimeout
.
const [isLoadingSlow, setIsLoadingSlow] = React.useState(false);
const { data } = useSWR("pokemon", fetcher, {
loadingTimeout: 1000,
onLoadingSlow(key, config) {
setIsLoadingSlow(true);
},
});
React.useEffect(() => {
if (data && isLoadingSlow) {
const timer = setTimeout(setIsLoadingSlow, 3000, false);
return () => clearTimeout(timer);
}
}, [data, isLoadingSlow]);
if (!data && !isLoadingSlow) {
return null;
}
if (isLoadingSlow) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Con esto cambiamos nuestro tiempo de espera a un segundo, más de este tiempo y vamos a considerar que tarda en cargar y se va a ejecutar onLoadingSlow
.
Manejo de Errores
Manejando errores
En un mundo ideal todos nuestros request al API van a funcionar siempre y nunca vamos a tener errores.
En el mundo real esto no pasa, y un request puede fallar por muchas razones, un error del servidor, falta de internet, un 404, un acceso no autorizado, etc.
Debido a esto es necesario que nuestros componente manejen correctamente este caso donde falle. Por suerte para nostros SWR viene con varias herramientas para esto. Lo primero que podemos hacer es saber si hubo un error y cual fue, así que vamos a empezar por cambiando nuestro fetcher
para que falle siempre así podemos probar este caso.
function failingFetcher() {
throw new Error("This is an error");
}
Con esto podemos cambiar nuestro componente para que use este failingFetcher
.
const { data } = useSWR("pokemon", failingFetcher);
if (!data) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Si intentamos usar nuestra aplicación van a ver que se mantiene por siempre en el estado de carga, esto porque como ocurrió un error data
se mantuvo como undefined
. Para acceder al error podemos usar la propiedad error
que SWR nos devuelve.
const { data, error } = useSWR("pokemon", failingFetcher);
Con esto podemos agregar un console.log(error.message)
y ver el mensaje de error en la consola, van a ver que al igual que con data
la propiedad error
es inicialmente undefined
.
Lo siguiente que tenemos que hacer es mostrar este error de alguna forma al usuario para dejarle saber que algo falló, así que podemos agregar un if
antes de nuestro estado de carga.
const { data, error } = useSWR("pokemon", failingFetcher);
if (error) {
return <p>Something failed: {error.message}</p>;
}
if (!data) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Es importante que nuestra condición para saber si hay un error esté antes que el estado de carga, esto es porque como vimos antes data
es undefined
si hay un error, si ponemos esa condición primero vamos a ver nuestro mensaje de Loading Pokémon...
por siempre y nunca vamos a ver el error.
Configurando los reintentos en caso de error
Muchas veces un error puede ocurrir por problemas temporales, como falta de conexión o un error 500, en estos casos en vez de mostrar el error y quedarnos ahí lo que SWR hace es reintentar el request, esto lo hace volviendo a llamar a nuestro fetcher varias veces después de un tiempo.
Cuanto tiempo pasa entre requests? Es dinámico, va creciendo siguiente con algoritmo conocido como exponential backoff, donde la idea es que cada re-intente tarda más que el anterior.
Todo esto es completamente configurable, lo primero que podemos hacer es desactivarlo usando la opción shouldRetryOnError
, para pasar opciones a SWR lo hacemos con un objeto como tercer argumento de la función.
useSWR("pokemon", failingFetcher, { shouldRetryOnError: false });
Con esto configuramos que no reintente, aunque normalmente lo mejor es dejarlo activado, pero es posible que necesitemos configurar cada cuanto va a reintentar, capaz queremos que sea cada un segundo siempre sin usar el exponential backoff, esto lo podemos cambiar con onErrorRetry
, esta opción es una función que recibe varios argumentos
error
con el error recibidokey
con la key usada al hacer el fetchconfig
las opciones usadas al configurar SWRrevalidate
es una función con la que podemos volver a intentar el request{ retryCount }
es cuantas veces ya hemos reintentado
useSWR("pokemon", failingFetcher, {
onErrorRetry(error, key, config, revalidate, { retryCount }) {
if (key === "pokdemon/pikachu") return;
if (retryCount >= 10) return;
setTimeout(() => revalidate({ retryCount: retryCount + 1 }), 5000);
},
});
Como vemos podemos configurar completamente como funciona, en el ejemplo de arriba no reintamos si la key es pokemon/pikachu
, tampoco si llegamos a diez reintentos. Por último después de cinco segundos llamamos a revalidate
incrementando retryCount
en uno.
También, es posible pasar un errorRetryCount
y errorRetryInterval
como opciones para configurar el limite de reintentos o el intervalo.
useSWR("pokemon", failingFetcher, {
errorRetryCount: 10,
errorRetryInterval: 5000,
});
De esta forma es posible configurar como funciona el reintento sin hacer una función propia.
Reportando Errores
A veces, es posible que necesitemos hacer algo cuando ocurra un error, capaz usamos un servicio como Sentry para registrar los errores de nuestra aplicación, para esto tenemos dos opciones.
La primera opción es que usemos un efecto para que si hay un error lo reportemos a nuestro servicio.
const { error } = useSWR("pokemon", failingFetcher);
React.useEffect(() => {
if (!error) return;
report(error);
}, [error]);
La segunda opción es que usemos la opción onError
que viene con SWR.
useSWR("pokemon", failingFetcher, {
onError(error, key, config) {
report(error);
},
});
Con eso no necesitamos crear un efecto extra y SWR se va a encargar de llamar a nuestra función cada vez que falle un request.
Reusabilidad
Compartiendo y reusando requests
Una vez nuestra aplicación empiece a crecer, es normal reusar la misma información en varias partes, de hecho es preferible para evitar hacer requests innecesarios. SWR nos ayuda con esto gracias a evitar requests duplicados.
Si usamos varias veces la misma key
de SWR lo que hace la librería es solo ejecutar un request, hasta que pasen al menos dos segundos. También es configurable, en este caso usando la opción dedupingInterval
.
useSWR("pokemon", fetcher, { dedupingInterval: 5000 });
Con ese cambia cualquier petición dentro de un rango de cinco segundos va a re-usar el resultado del request anterior evitando así requests duplicados e innecesarios.
Adicionalmente si vamos a usar el mismo request muchas veces podemos crear un Hook que configure SWR siempre de la misma forma, veamos como.
function usePokemon() {
return useSWR("pokemon", fetcher);
}
Luego podemos llamar a nuestro Hook de la siguiente forma:
const { data, error } = usePokemon();
if (error) {
return <p>Something failed: {error.message}</p>;
}
if (!data) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Es decir que ahora podemos llamar todas las veces que necesitemos a usePokemon
y siempre va a venir correctamente configurado para nuestro caso de uso y con la misma key
y fetcher
, asegurándonos que no cambie la key
por un error y terminemos haciendo otro request.
Creando indicadores de carga y error
Hasta ahora vimos que para saber si hay un error verificamos que error
exista y para ver si está cargando verificamos que error
y data
no existan. Ya que creamos nuestro propio Hook usePokemon
podemos cambiar un poco el valor devuelto por el Hook para agregar una propiedad status
o propiedades isLoading
o isError
.
function getStatus({ data, error }) {
if (error && !data) return "error";
if (!data) return "loading";
return "success";
}
function usePokemon() {
const { data, error } = useSWR("pokemon", fetcher);
const status = getStatus({ data, error });
const isLoading = status === "loading";
const isError = status === "error";
const isSuccess = status === "success";
return { isLoading, isError, isSuccess, data, error };
}
Como podemos ver, usando status
es posible conseguir el valor de las constantes booleanas.
Ahora si cambiamos nuestro código podríamos tener algo similar a esto:
const { status, data, error } = usePokemon();
switch (status) {
case "error":
return <p>Something failed: {error.message}</p>;
case "loading":
return <p>Loading Pokémon...</p>;
case "success":
default:
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
}
Lo cual queda un poco más simple, o usando las constantes booleanas, podemos tener algo similar a esto:
const { data, error, isLoading, isError } = usePokemon();
if (isError) {
return <p>Something failed: {error.message}</p>;
}
if (isLoading) {
return <p>Loading Pokémon...</p>;
}
return (
<div>
{data?.results.map((pokemon) => <h2>Hello {pokemon.name}</h2>) ?? null}
</div>
);
Lo cual, si bien no es más corto, es más fácil de entender a simple vista que está pasando y que significa cada condición.
Definiendo la data inicial
En algunos casos es muy posible que ya tengamos toda o parte de la data inicial que necesitamos para una key específica.
Esto es muy común si usamos un framework como Next.js que nos permite hacer SSR (Server-Side Rendering) o SSG (Static Site Generation), de forma que obtenemos la data en sus método getServerSideProps
o getStaticProps
y luego podemos leerlos desde nuestros componentes.
Otro caso de uso es que en una parte de nuestra aplicación tengamos una lista de elementos y luego tenemos cada elemento de forma individual con su propia key, podríamos usar la data de la lista para ir llenando la del elemento individual.
Para ambos casos SWR nos deja usar la opción initialData
donde podemos pasar que queremos que nuestro Hook tenga como valor inicial.
useSWR("pokemon", fetcher, {
initialData: {
count: 1050,
next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
previous: null,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
],
},
});
Con esto nuestro Hook va a empezar usando nuestro initialData, algo importante a tener en cuenta es que al usar esta opción SWR desactiva revalidateOnMount
por lo que no va a intentar hacer fetch para revalidar la data inicial.
Para forzarlo a que lo haga podemos pasar esta opción como true
.
useSWR("pokemon", fetcher, {
revalidateOnMount: true,
initialData: {
count: 1050,
next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
previous: null,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
],
},
});
Ahora sí, con esto ya hicimos que nuestro Hook usePokemon
va a iniciar siempre con una lista de solo Bulbasaur, podríamos agregar más fijos, o podríamos pasarlos como parámetros a nuestro Hook personalizado, veamos como.
function getStatus({ data, error }) {
if (error && !data) return "error";
if (!data) return "loading";
return "success";
}
function usePokemon({ initialData } = {}) {
const { data, error } = useSWR("pokemon", fetcher, { initialData });
const status = getStatus({ data, error });
const isLoading = status === "loading";
const isError = status === "error";
const isSuccess = status === "success";
return { isLoading, isError, isSuccess, data, error };
}
Ahora podemos pasar el initialData
cuando lo necesitemos, por ejemplo podríamos leer su valor de localStorage
.
usePokemon({ initialData: JSON.parse(localStorage.getItem("pokemon")) });
Otra cosa a tener en cuenta es que SWR no guarda nuestra data inicial en su cache, por lo que esta data es individual por cada instancia de SWR, si usamos dos veces la misma key necesitamos pasar la data inicial a ambas partes.
Revalidando Data
Revalidación automática
En muchos casos SWR va a revalidar la data que tiene almacenada en cache, esto nos ayuda a asegurarnos que tengamos siempre la data actualizado, es esta funcionalidad la que le da el nombre SWR (stale while revalidate) lo que hace que SWR nos de información potencialmente desactualizada mientras revalida por detrás para actualizarla.
Veamos que tipos de revalidaciones hace SWR.
Al montarse
Cuando un componente se monta y usa SWR vamos a obtener la información en cache y luego inmediatemente después la librería va a ejecutar nuestro fetcher para revalidarla.
Este comportamiento lo podemos desactivar con la opción revalidateOnMount
.
useSWR("pokemon", fetcher, { revalidateOnMount: false });
*Al recuperar foco la aplicación
Nuestra aplicación corre en un tab del navegador del usuario, esto significa que el usuario puede fácilmente cambiar de tab y no volver a nuestra aplicación por minutes, horas, hasta días.
Y no solo puede cambiar de tab, puede incluso cambiar de ventana a otra aplicación de su computadora y no volver a su navegador en un tiempo (piensen en cuando cambian de su navegador a un editor de codigo).
Incluso puede ocurrir cuando el usuario deja nuestra aplicación abierta y cierra su laptop o suspende su computadora.
Cuando el usuario vuelve a nuestra aplicación web, a tener el foco en el tab y ventana del navegador donde corre, se dispara un evento en el navegador. SWR escucha ese evento y revalida la data que tenga en su cache, para asegurarse de que, si pasó mucho tiempo, nos actualicemos a los últimos cambios.
Este comportamiento lo podemos desactivar con la opción revalidateOnFocus
.
useSWR("pokemon", fetcher, { revalidateOnFocus: false });
Al reconectarse
Es muy común que el dispositivo de nuestros usuarios pierda conexión a internet. Especialmente cuando está usando su celular y está en movimiento (en un bus por ejemplo). Si esto pasa la perdida puede ser muy corta o puede ser de varios minutos u horas.
Al igual que con el foco del tab, hay un evento en los navegadores para saber cuando cambia la conectividad del usuario, SWR lo usa para revalidar la cache cuando el usuario recupera conexión, de esta forma si pasó media hora sin internet va a volver a poner al día con la data sin recargar.
Este comportamiento lo podemos desactivar con la opción revalidateOnReconnect
.
useSWR("pokemon", fetcher, { revalidateOnReconnect: false });
Revalidación manual
También es posible que necesitemos revalidar manualmente nuestra data, esto puede ser muy útil cuando sabemos, por ejemplo luego de que el usuario envíe un formulario.
Para iniciar una revalidación manual SWR nos da dos formas.
Función mutate global
La primera es la función mutate
que podemos importar en cualquier parte de nuestra aplicación de SWR.
import useSWR, { mutate } from "swr";
Esta función recibe como primer argumento la key
de cache que queremos revalidar.
mutate("pokemon");
Una vez que termine mutate
también se van a actualizar automaticamente todos los componentes montados que usen la key de cache que actualizamos.
Función mutate local
La otra opción es la función mutate
que obtenemos de SWR.
const { mutate } = useSWR("pokemon", fetcher);
La única diferencia con la función mutate
global es que esta no necesita la key
, ya viene configurada para el Hook de SWR que la generó.
Incluso podemos devolver esta función como parte de nuestro Hook personalizado
function getStatus({ data, error }) {
if (error && !data) return "error";
if (!data) return "loading";
return "success";
}
function usePokemon({ initialData } = {}) {
const { data, error, mutate } = useSWR("pokemon", fetcher, { initialData });
const status = getStatus({ data, error });
const isLoading = status === "loading";
const isError = status === "error";
const isSuccess = status === "success";
return { isLoading, isError, isSuccess, data, error, mutate };
}
Polling
En muchas aplicaciones la data que manejamos puede cambiar en cualquier momento, especialmente aplicaciones de trabajo colaborativas como pueden ser Trello, o cuyo contenido es principalmente generado por el usuario, como Twitter.
En estos casos esperar a que ocurra una revalidación puede tomar mucho tiempo, por lo que necesitamos volver nuestra aplicación Real-Time, sin embargo, trabajar con WebSockets es complicado, una solución más sencilla sería usar polling a long-polling.
SWR nos permite hacer esto última de forma muy sencilla.
Activando la opción refreshInterval
cuyo valor por defecto es cero (desactivado) SWR va a empezar a mandar request en el intervalo configurado.
useSWR("pokemon", fetcher, { refreshInterval: 5000 });
En el ejemplo de arriba, SWR va a volver a traerse la lista de Pokémon cada cinco segundos y si algo cambió va actualizar la cache y todos los componentes usándo esa key
.
Inicialmente, SWR va a dejar de hacer polling si el usuario no está viendo el navegador o si está offline, usando las opciones refreshWhenHidden
y refreshWhenOffline
podemos cambiar este comportamiento, aunque en general es recomendable dejarlo desactivado.
¿Por qué?
En el primer caso podemos evitar seguir haciendo requests si el usuario no usa nuestra aplicación, es común, especialmente en apps como Twitter o Trello, que se deje el tab abierto mientras se hace otra cosa por lo que sería mejor evitar hacer peticiones innecesarias.
En el segundo caso porque si el usuario está offline no tiene sentido seguir intentado hacer requests.
useSWR("pokemon", fetcher, {
refreshInterval: 5000,
refreshWhenHidden: true,
refreshWhenOffline: true,
});
Mutaciones
Mutando la data local
Antes vimos que podemos forzar una revalidación manual usando al función mutate
, ya sea la global o la local. Pero ¿Por qué se llama mutate
y no revalidate
? Esto es porque si bien podemos ejecutar mutate(key)
para generar una revalidación también podemos usar la misma función para modificar directamente la data en cache.
Esto nos sirve mucho si queremos reflejar un cambio que hizo el usuario de inmediato.
mutate("pokemon", {
count: 1050,
next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
previous: null,
results: [{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }],
});
Cuando ejecutemos nuestra función mutate
vamos a reemplazar la data de la key pokemon
con lo que acabamos de pasar como segundo argumento (si usamos la versión local de mutate
pasamos la nueva data como primer argumento).
Algo importante a tener en cuenta es que para asegurarse de que la data esté siempre al día SWR va a generar una revalidación luego de actualizar la cache. De esta forma si nuestra data no está persistida en el servidor la cache se va a volver a actualizar con la información correcta.
Podemos desactivar este comportamiento pasando un false
como tercer argumento (o segundo para la versión local), con esto le decimos que no debe revalidar.
mutate(
"pokemon",
{
count: 1050,
next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
previous: null,
results: [
{ name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" },
],
},
false
);
Mutando la data local con base a la actual
Algunas veces un componente tiene parte de la data que queremos guardar en la cache pero no tiene toda, por lo que reemplazar toda la data en cache no es posible. Para estos casos SWR nos permite pasar una función que va a recibir la data actual de la key que definimos.
mutate("pokemon", function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
});
De esta forma podemos mutar con base a la data actual sin necesitar estar pasando toda la data de un lugar a otro o llamar a nuestro Hook en varias partes. Esto es muy útil cuando tenemos un componente de formulario y no queremos que se vuelva a renderizar cada vez que cambia la data, pero si necesitamos actualizarla.
Mutando la data local de forma asíncrona
Casi siempre que necesitemos mutar la data local es porque creamos, editamos o borramos algún dato. La forma más común de hacer esto es que primero hagamos nuestro request y luego mutemos.
await fetch("/api/v2/pokemon", { method: "POST", body });
mutate("pokemon", function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
});
SWR nos permite pasar una función asíncrona, al hacerlo mutate
va a esperar a que esta se complete y usar su resultado como nuevo valor de la cache, por lo que podríamos combinar todo en una sola función.
await mutate("pokemon", async function createPokemon(current) {
await fetch("/api/v2/pokemon", { method: "POST", body });
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
});
Si nuestro API nos devuelve la data actualiza (por ejemplo la lista con el nuevo elemento), podemos directamente retornar el resultado del request.
await mutate("pokemon", async function createPokemon() {
return fetch("/api/v2/pokemon", { method: "POST", body }).then((res) =>
res.json()
);
});
Incluso podemos ir un paso más adelante y eliminar nuestra función createPokemon
y pasar directamente la promesa.
await mutate(
"pokemon",
fetch("/api/v2/pokemon", { method: "POST", body }).then((res) => res.json())
);
Con esto, SWR va a esperar a que la promesa se complete y usar el resultado directamente.
Si, por alguna razón, necesitamos la data que obtenemos del request, mutate
también nos devuelve la data.
try {
const data = await mutate(
"pokemon",
fetch("/api/v2/pokemon", { method: "POST", body }).then((res) => res.json())
);
} catch (error) {
// hacé algo con el error acá
}
Si la promesa falla, mutate
va a lanzar un error y vamos a poder atraparlo usando try/catch o .catch()
. Esto pasa siempre que llamemos a mutate
, ya sea que su segundo valor (o primero para la versión local) sea una promesa, una función asíncrona o una función síncrona, siempre vamos a obtener el resultado. Incluso si no pasamos nada y solo hacemos una revalidación vamos a obtener la nueva información de la cache.
Uso avanzado de las keys
Pasando argumentos por la key
Hasta ahora, usamos SWR pasándo parte de la URL y nuestro fetcher recibía esto como argumento.
function fetcher(path) {
const url = `https://pokeapi.co/api/v2/${path}/`;
return fetch(url).then((res) => res.json());
}
Esto nos permite reusar nuestro fetcher con distintos paths sin tener que hacer un fetcher por cada posible URL. Podemos hacerlo porque SWR pasa la key como argumento a nuestro fetcher, pero ¿Qué ocurre si necesitamos más de un argumento? Lo más normal es usar un string con la URL pero no es necesario, ya que podemos usar un array como key. Veamos como funcionaría:
useSWR(["pokemon", 25], pokemonFetcher);
¿Qué creés que recibiría pokemonFetcher
en este caso? La respuesta es que SWR llama a nuestro fetcher pasándo cada elemento del array como un argumento a parte, por lo que la definición sería:
function pokemonFetcher(_, number) {
const url = `https://pokeapi.co/api/v2/pokemon/${number}`;
return fetch(url).then((res) => res.json());
}
El
_
como primer argumento es una convención para decir que no usamos el argumento, pero por como funciona el lenguaje necesitamos ponerle un nombre, así que usamos_
.
Con esto ahora podemos tener las keys ["pokemon", 1]
, ["pokemon", 2]
, ["pokemon", 3]
, etc. y usar "pokemon"
como prefijo en vez de la URL. Podríamos crear otro fetcher más genérico aún si hacemos:
function entityFetcher(resource, id) {
const url = `https://pokeapi.co/api/v2/${resource}/${id}`;
return fetch(url).then((res) => res.json());
}
Y ahora podríamos usarlo de esta forma:
useSWR(["pokemon", 25], entityFetcher);
useSWR(["item", 1], entityFetcher);
useSWR(["trainer", 123], entityFetcher);
De esta forma el primer valor del array que usamos como key funciona como nombre del recurso y prefijo, y el segundo funciona como ID del recurso cuya entidad queremos traernos del API.
Requests condicionales
Algunas veces queremos evitar hacer un request hasta que alguna condición se cumpla, esto puede ser útil si usamos SWR para hacer una búsqueda y no queremos hacer requests hasta que el usuario escriba algo en un input. Para esto tenemos dos opciones:
Crear un componente hijo
La primera opción es que creemos un componente hijo donde hagos la búsqueda y solo rendericemos ese componente si el usuario escribió algo. Veamos un ejemplo:
function search(_, query) {
return fetch(`/api/search?query=${query}`).then((res) => res.json());
}
function SearchResults({ query }) {
const { data, error } = useSWR(["search", query], search);
if (error) return <p>Something happened :(</p>;
if (!data) return <p>Searching...</p>;
return data?.map((item) => <ResultItem key={item.id} {...item} />);
}
function SearchBox() {
const [searchQuery, setSearchQuery] = React.useState("");
const handleChange = React.useCallback(
function handleChange(event) {
setSearchQuery(event.target.value);
},
[setSearchQuery]
);
return (
<div>
<input value={searchQuery} onChange={handleChange} />
{searchQuery !== "" ? <SearchResults query={searchQuery} /> : null}
</div>
);
}
Con esto vamos a renderizar SearchResults
solo cuando searchQuery
no esté vacío.
Esta opción está buena si además del request queremos evitar otros efectos o por alguna razón hacer el render es pesado. Esta forma es la que deberías usar siempre que sea posible ya que además te va a dar un mejor performance general de tu app.
Usando una key condicional
La segunda opción es que usemos una key condicional. SWR nos permite poner null
como key para evitar hacer el request, esto significa que podemos definir la key como null
cuando searchQuery
está vacío.
function search(key, query) {
return fetch(`/api/search?query=${query}`).then((res) => res.json());
}
function SearchBox() {
const [searchQuery, setSearchQuery] = React.useState("");
const { data, error } = useSWR(
searchQuery !== "" ? ["search", qusearchQueryery] : null,
search
);
const handleChange = React.useCallback(
function handleChange(event) {
setSearchQuery(event.target.value);
},
[setSearchQuery]
);
return (
<div>
<input type="search" value={searchQuery} onChange={handleChange} />
{searchQuery === "" ? <p>Write something to search</p> : null}
{error ? <p>Something happened :(</p> : null}
{!data && searchQuery !== "" ? <p>Searching...</p> : null}
{data ? data?.map((item) => <ResultItem key={item.id} {...item} />) : null}
</div>
);
}
Con esto tenemos un solo componente donde podemos manejar tanto el estado searchQuery
como nuestro request usando SWR. Si searchQuery
cambia se actualiza el request y si se vuelve a poner en null
se borra.
Otra cosa es que ahora vamos a tener que identificar cuando data
es undefined
porque no hay resultados todavia y cuando es undefined
porque searchQuery
está vacío, lo que nos agrega un cuarto estado, teniendo:
- No está buscando
- Buscando (cargando)
- Algo falló
- Hay resultado
Requests dependientes
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!
Usando data paginada
Paginación Normal
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!
Paginación Infinita
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!
Contenido Bonus
Prefetching
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!
Suspense
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!
Actualizaciones optimistas de la UI
Una actualización optimista, u Optimistic Update en inglés, signfica que primero vamos a actualizar la UI como si nuestro request fuese un éxito, y luego vamos a hacer el request, en caso de que falle, vamos a dar marcha atrás al cambio y dejarlo en el estado anterior. Este patrón es muy usado en aplicaciones como Twitter o Facebook por ejemplo al dar like a un tweet o publicación, si ocurre un error podemos ver como se elimina nuestra like.
Tené en cuanta que no todas las interacciones de nuestra app se pueden volver optimistas, por ejemplo al crear un nuevo elemento es posible que varios datos se creen en el servidor, como pueden ser el ID o la fecha de creación, en estos casos lo mejor es mostrar una UI de carga y recién actualizar cuando se tenga el resultado del request.
Hasta ahora, vimos como hacer el request y la mutación en serie o hacerlos a la vez, en ambos casos la UI no se va a actualizar hasta que terminemos de hacer el request.
Veamos como implementar este patrón.
Lo primero es que necesitamos poder saber el resultado, si tomamos el siguiente ejemplo de base.
await fetch("/api/v2/pokemon", { method: "POST", body });
mutate("pokemon", function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
});
En ese caso sabemos el Pokémon que hay que agregar, por lo que podemos cambiar el orden y evitar una revalidación.
mutate(
"pokemon",
function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
},
false
);
await fetch("/api/v2/pokemon", { method: "POST", body });
Con esto acabamos de usar implementar nuestra actualización de forma optimista, cambiamos la UI y hacemos el request, pero todavía nos falta una parte ¿Qué ocurre si falla el request? Debemos volver al estado anterior, para esto tenemos dos opciones.
Guardar la data actual y reemplazar al fallar
En este caso, lo que vamos a hacer es obtener la data actual, si no tenemos acceso al Hook de SWR podemos importar la cache de SWR directamente y leer de ahí.
const current = cache.get("pokemon");
Luego podemos, en caso de error, mutar nuestra cache con la data vieja que guardamos anteriormente.
const current = cache.get("pokemon");
try {
mutate(
"pokemon",
function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
},
false
);
await fetch("/api/v2/pokemon", { method: "POST", body });
} catch {
mutate("pokemon", current);
}
Al final como vemos reemplazamos la cache con lo que estaba antes, y de paso dejamos que SWR revalide la data con el servidor, para estar seguros.
Revalidar al terminar o en caso de error
La segunda opción es más sencilla, acá lo que hacemos es generar una revalidación, podemos hacerlo en caso de un error como vemos debajo.
try {
mutate(
"pokemon",
function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
},
false
);
await fetch("/api/v2/pokemon", { method: "POST", body });
} catch {
mutate("pokemon");
}
O podemos revalidar siempre, para estar seguros.
mutate(
"pokemon",
function appendPokemon(current) {
return {
...current,
results: current.results.concat({
name: "entei",
url: "https://pokeapi.co/api/v2/pokemon/244/",
}),
};
},
false
);
await fetch("/api/v2/pokemon", { method: "POST", body });
mutate("pokemon");
Con todo esto, ya tenemos nuestra actualización optimista de la UI lista y funcionando.
Real Time con WebSockets
¿Te interesa este tema en particular? ¿Dejame saber en Twitter!