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 recibido
  • key con la key usada al hacer el fetch
  • config las opciones usadas al configurar SWR
  • revalidate 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!