react-estados-carga-error

Manejo de estados de carga y error en React

  • 5 min

Los estados de carga son las fases por las que pasa una interfaz mientras espera datos externos.

Cuando una aplicación React pide datos a una API, la pantalla no debería quedarse en blanco (eso da muy mala espina). Tenemos que decirle al usuario qué está pasando: estamos cargando, algo ha fallado, no hay datos, o ya tenemos contenido.

En el artículo anterior vimos cómo hacer una petición con fetch. Ahora vamos a profundizar un poco más en la interfaz alrededor de esa petición.

Los cuatro estados habituales

Una petición HTTP no tiene solo dos resultados. En una interfaz real, normalmente manejamos cuatro estados distintos:

  1. Loading: La petición está en marcha.
  2. Error: La petición ha fallado.
  3. Empty: La petición ha ido bien, pero no hay datos.
  4. Success: La petición ha ido bien y tenemos datos que pintar.

No confundáis sin datos todavía con sin resultados. null, [] y "error" no significan lo mismo, aunque a veces los metemos todos en el mismo cajón y luego pasa lo que pasa.

Modelo básico con varios estados

La forma más directa es usar tres estados separados: users, loading y error.

import { useEffect, useState } from "react";

export default function ListaUsuarios() {
  const [users, setUsers] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    const loadUsers = async () => {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users",
          { signal: controller.signal }
        );

        if (!response.ok) {
          throw new Error(`Error HTTP: ${response.status}`);
        }

        const data = await response.json();
        setUsers(data);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err.message);
          setUsers(null);
        }
      } finally {
        setLoading(false);
      }
    };

    loadUsers();

    return () => controller.abort();
  }, []);

  if (loading) {
    return <p>Cargando usuarios...</p>;
  }

  if (error) {
    return <p>No se han podido cargar los usuarios: {error}</p>;
  }

  if (!users || users.length === 0) {
    return <p>No hay usuarios para mostrar.</p>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
Copied!

El orden de los return importa. Primero resolvemos los estados especiales (loading, error, empty) y dejamos al final el caso feliz. Así el JSX principal queda limpio y no acaba convertido en una sopa de ternarios.

Extraer la carga a una función reutilizable

Si queremos añadir un botón de reintento, conviene sacar la función de carga fuera del useEffect. Así podemos llamarla tanto al montar como al pulsar el botón.

import { useCallback, useEffect, useState } from "react";

export default function ListaUsuarios() {
  const [users, setUsers] = useState([]);
  const [status, setStatus] = useState("idle");
  const [error, setError] = useState(null);

  const loadUsers = useCallback(async () => {
    try {
      setStatus("loading");
      setError(null);

      const response = await fetch("https://jsonplaceholder.typicode.com/users");

      if (!response.ok) {
        throw new Error(`Error HTTP: ${response.status}`);
      }

      const data = await response.json();
      setUsers(data);
      setStatus("success");
    } catch (err) {
      setError(err.message);
      setStatus("error");
    }
  }, []);

  useEffect(() => {
    loadUsers();
  }, [loadUsers]);

  if (status === "loading") return <p>Cargando...</p>;

  if (status === "error") {
    return (
      <div>
        <p>Error: {error}</p>
        <button onClick={loadUsers}>Reintentar</button>
      </div>
    );
  }

  if (status === "success" && users.length === 0) {
    return <p>No hay usuarios.</p>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
Copied!

Usar un status con valores como "idle", "loading", "success" y "error" evita combinaciones raras, como loading: true y error: "algo" al mismo tiempo.

Skeletons mejor que spinners eternos

Para cargas muy rápidas, un texto de “Cargando…” puede ser suficiente. Para interfaces más grandes, suelen funcionar mejor los skeleton screens: bloques grises que imitan la forma del contenido final.

function UserSkeleton() {
  return (
    <div className="skeleton-card">
      <div className="skeleton-title" />
      <div className="skeleton-line" />
    </div>
  );
}
Copied!

Esto le da al usuario una pista de lo que va a aparecer y reduce la sensación de espera. No hace que la API responda antes, pero la espera se percibe mejor (que también cuenta).

Evitar interfaces que parpadean

Un error típico es poner loading a true en cada pequeña actualización, incluso cuando ya tenemos datos en pantalla. El resultado es una interfaz que desaparece y reaparece continuamente.

Para refrescos secundarios, podéis mantener los datos antiguos y mostrar un indicador más discreto:

return (
  <section>
    {status === "loading" && users.length > 0 && (
      <small>Actualizando datos...</small>
    )}

    <UserList users={users} />
  </section>
);
Copied!

Así diferenciamos entre carga inicial y actualización en segundo plano. El usuario lo agradece, aunque no sepa ponerle nombre.