Usa React.Suspense para controlar la carga de imagenes

Aprende como usar React.Suspense para controlar el estado de carga de imágenes.

Nota: Usar React.Suspense para cualquier cosa excepto carga asíncrona de components es todavía inestable.

Aunque React.Suspense es todavía inestable ya podemos empezar a usarlo con su implementación actual, en este caso podemos usarlo para controlar el estado de carga de una imagen, pero ¿Por qué es esto útil? Usando React.Suspense podemos evitar renderizar un componente hasta que sus imágenes hayan terminado de cargar, evitando así saltos en el contenido debido a que tarda mucha en cargar.

Lo primero que vamos a hacer es crear una función para interactuar con recursos, un recurso es cualquier cosa que podamos pedir de un servidor y guardar en una cache.

// Un objeto Resource tiene un método read que nos devuelve el Payload
interface Resource<Payload> {
  read: () => Payload;
}

// Y vamos a manejar tres posible estados, pendiente, exitoso y error
type status = "pending" | "success" | "error";

// createResource recibe una función asíncrona (asyncFn) que devuelve una
// promesa con el Payload del recurso que pasamos como tipo de dato
// el resultado de createResource es un objeto Resource cuyo método read
// es el mismo Payload que pasamos a createResource
function createResource<Payload>(
  asyncFn: () => Promise<Payload>
): Resource<Payload> {
  // empezamos definiendo que status es pending
  let status: status = "pending";
  // y creamos una variable para guardar el resultado de asyncFn
  let result: any;
  // después ejecutamos asyncFn de inmediato y guardamos la promesa
  const promise = asyncFn().then(
    (r: Payload) => {
      // cuando se resuelva exitosamente la promesa cambiamos el status
      // a que fue un éxito y guardamos el resultado 
      status = "success";
      result = r;
    },
    (e: Error) => {
      // si la promesa se resuelve con un error cambiamos el status a error
      // y guardamos el error como resultado
      status = "error";
      result = e;
    }
  );
  // luego devolvemos nuestro objeto Resource
  return {
    read(): Payload {
      // dentro de `read vamos verificar el status
      switch (status) {
        case "pending":
          // si está pendiente hacemos un throw de la promesa
          // hacienod esto React va a saber que nuestro componente no está
          // listo para renderizarse y lo va a suspender
          throw promise;
        case "error":
          // si el status es error hacemos un throw del error, esto permite
          // usar error boundaries para manejar el error
          throw result;
        case "success":
          // por último, si fue un éxito devolvemos el resultado
          return result;
      }
    },
  };
}

Con este createResource podríamos en realidad usar Suspense para caulquier tipo de data, pero vamos a usarlo solo para imágenes por ahora.

// Primero, vamos a crear una cache de recursos de imagenes, esto nos permite
// evitar volver a pedir una imagen que ya pedimos antes
const cache = new Map<string, any>();

// luego vamos a crear una función loadImage, esta función recibe como source
// la URL de la imagen y devuelve un Resource
function loadImage(source: string): Resource<string> {
  // lo primero que hacemos es obtener el recurso usando el source como ID
  let resource = cache.get(source);
  // y si existe lo devolvemos inmediatemente, evitando crear otro recurso
  if (resource) return resource;
  // pero si no existe creamos un nuevo recurso
  // but if it's not we create a new resource
  resource = createResource<string>(
    () =>
      // en nuestra asyncFn devolvemos una promesa
      new Promise((resolve, reject) => {
        // dentro vamos a crear una instancia de Image
        const img = new window.Image();
        // y vamos a definir el source como el atributo src
        img.src = source;
        // después vamos a escuchar el evento load y resolver la promesa pasando
        // el source como valor
        img.addEventListener("load", () => resolve(source));
        // y también escuchamos el evento error y rechazamos la promesa con un
        // error diciendo que falló la carga de la imagen y el source
        img.addEventListener("error", () =>
          reject(new Error(`Failed to load image ${source}`))
        );
      })
  );
  // antes del return, vamos a guardar el recurso en nuestra cache
  cache.set(source, resource);
  // y ahora si lo devolvemos
  return resource;
}

Con esto ya podemos empezar a usarlo, vamos a crear un componente SuspenseImage:

function SuspenseImage(
  props: React.ImgHTMLAttributes<HTMLImageElement>
): JSX.Element {
  loadImage(props.src).read();
  return <img {...props} />;
}

Este pequeño componente va a usar nuestra función loadImage para suspenderse hasta que la imagen haya terminado de cargar, ahora vamos a verlo en uso:

interface User {
  fullName: string;
  avatar: string;
}

function User({ fullName, avatar }: User) {
  return (
    <div>
      <SuspenseImage src={avatar} />
      <h2>{fullName}</h2>
    </div>
  );
}

function UserList({ users }: { users: User[] }) {
  return (
    <React.Suspense fallback={<>Loading users...</>}>
      {users.map((user) => <User key={user.id} {...user} />)}
    </React.Suspense>
  )
}

Con esto, cuando rendericemos UserList, este va a mostrar Loading users... como fallback hata que todas las imágenes hayan cargado, cuando esto ocurra va a renderizar todos los usuarios con sus avatares de una, sin dejar ningún espacio en blanco en el medio de la lista.