Frontend 10 ago 2026 · 5 min de lectura

Estados de carga: ese spinner centrado es una decisión de producto

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

Hay una pantalla que casi nadie diseña y que todos los usuarios ven: la que aparece mientras cargan los datos. En Figma llega la maqueta con los datos ya puestos, bonita y completa, y el estado de carga se resuelve en el último commit con un if (loading) return <Spinner />. Ese if es una decisión de producto tomada a las once de la noche por la persona que tenía prisa. Y se nota: la app se siente lenta, la pantalla salta cuando llegan los datos, y el usuario no sabe si esperar o recargar. He pasado años arreglando eso en dashboards, en un planificador de comidas y en paneles internos, y mi conclusión es que los estados de carga se ganan casi toda la percepción de velocidad que el usuario tiene de tu producto.

El spinner centrado es la peor respuesta por defecto

Un spinner en medio de la pantalla dice una sola cosa: "algo está pasando, no sé qué, no sé cuánto falta". Borra toda la estructura que ya conocías y la reemplaza por una ruedita. Cuando los datos llegan, la interfaz aparece de golpe en una posición distinta y tu ojo tiene que volver a orientarse.

El problema de fondo es que trata la pantalla como una unidad atómica: o está todo, o no hay nada. Pero casi nunca es cierto. En un dashboard típico, el título, la navegación, los filtros y la estructura de las tarjetas se conocen antes de pedir un solo byte al servidor. Lo único que falta son los números. Bloquear el 90% de la interfaz que ya podías pintar para esperar al 10% que no, es tirar rendimiento percibido a la basura.

La regla que aplico: pinta todo lo que ya sabes, y marca como pendiente solo lo que de verdad depende del servidor.

Skeletons que no saltan: reservar el espacio real

Un skeleton es un placeholder con la forma del contenido final. Funciona por dos razones: comunica qué va a aparecer y, sobre todo, reserva el espacio para que nada salte cuando llegue. Si tu skeleton mide 40px y la fila real mide 72px, has cambiado un spinner por un salto de layout, que es peor.

Por eso los construyo a partir del mismo componente, no como una maqueta paralela que se desincroniza a la primera:

function FilaCandidato({ datos }) {
  return (
    <li className="fila">
      <span className="avatar">{datos ? <img src={datos.avatar} alt="" /> : null}</span>
      <span className="nombre">{datos ? datos.nombre : <Bloque w="60%" />}</span>
      <span className="puesto">{datos ? datos.puesto : <Bloque w="35%" />}</span>
    </li>
  );
}

La fila tiene la misma altura y el mismo grid en ambos estados, así que el paso de skeleton a datos es un cambio de contenido, no de layout. El CSS hace el resto del trabajo pesado:

.fila { display: grid; grid-template-columns: 40px 1fr 1fr; min-height: 72px; }
.avatar { aspect-ratio: 1; border-radius: 50%; background: var(--gris-200); }

@media (prefers-reduced-motion: no-preference) {
  .bloque { animation: pulso 1.4s ease-in-out infinite; }
}

Dos detalles que se olvidan siempre: min-height en la fila (evita el CLS cuando el contenido real es más alto) y respetar prefers-reduced-motion, porque una pantalla llena de bloques pulsando es exactamente el tipo de animación que marea a alguna gente.

Streaming con Suspense: no esperes al dato más lento

Cuando una página pide cuatro cosas, lo normal es que tarden 80ms, 120ms, 150ms… y 900ms. Si esperas a todas para pintar, tu página tarda 900ms. Con streaming, el servidor manda el HTML de lo que ya está listo y el resto llega después por la misma conexión.

En React esto se expresa con fronteras de Suspense. Cada frontera es una promesa al usuario: "esta parte va a llegar más tarde, el resto ya está aquí".

export default function Panel() {
  return (
    <Layout>
      <Cabecera />                        {/* instantáneo, sin datos */}
      <Suspense fallback={<KpisSkeleton />}>
        <Kpis />                          {/* rápido */}
      </Suspense>
      <Suspense fallback={<TablaSkeleton filas={8} />}>
        <TablaInformes />                 {/* el lento: 900ms */}
      </Suspense>
    </Layout>
  );
}

Lo importante no es la sintaxis, es dónde pones las fronteras. Una frontera por página no sirve de nada: vuelves al spinner global con más pasos. Una frontera por componente diminuto tampoco: acabas con una pantalla que parpadea por trozos durante dos segundos y se siente rota. Mi criterio es poner la frontera alrededor de bloques que el usuario percibe como una unidad —una tarjeta, una tabla, un panel lateral— y sobre todo aislar lo lento para que no contagie a lo rápido.

El detalle que más quejas me quitó: retrasar y sostener

Si una petición tarda 90ms y muestras un skeleton, el usuario ve un parpadeo: aparece y desaparece antes de que el ojo lo procese. Ese flash se percibe como un glitch, no como velocidad. Y si el skeleton se muestra 40ms, molesta más que si no hubiera aparecido.

La solución no es técnica, es de tiempos: no muestres el estado de carga antes de ~200ms, y si lo muestras, sostenlo al menos ~400ms.

export function useCargaVisible(cargando: boolean) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!cargando) return;
    const espera = setTimeout(() => setVisible(true), 200);
    return () => clearTimeout(espera);      // terminó antes: nunca se vio
  }, [cargando]);

  useEffect(() => {
    if (cargando || !visible) return;
    const minimo = setTimeout(() => setVisible(false), 400);
    return () => clearTimeout(minimo);      // ya visible: aguanta 400ms
  }, [cargando, visible]);

  return visible;
}

Con esto, las respuestas rápidas no muestran nada —que es lo que quieres, porque ya son rápidas— y las lentas muestran un estado estable en vez de un parpadeo. Es el cambio más pequeño que he hecho con más impacto directo en "oye, la app va mucho mejor ahora".

Lo que hago hoy en cada pantalla nueva

Antes de escribir el primer fetch, me hago tres preguntas: qué puedo pintar sin datos (eso va fuera de cualquier frontera), qué bloques percibe el usuario como unidades (ahí van las fronteras de Suspense y sus skeletons), y cuál es el dato lento (ese se aísla siempre, aunque sea el más importante de la pantalla).

El error mental que me costó años corregir fue pensar en la carga como un momento binario —cargando o cargado— cuando en realidad es una secuencia que puedes coreografiar. La misma petición de 900ms puede sentirse como una app rota o como una app viva, y la diferencia no está en el backend: está en qué decides mostrar durante esos 900ms. Optimizar la petición de 900 a 600 cuesta una tarde de trabajo y casi nadie lo nota; diseñar bien lo que se ve mientras tanto cuesta una hora y lo nota todo el mundo.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

AWS

CloudFront delante de tu app: por qué tu hit rate es del 30%

Frontend

INP: por qué tu app se siente lenta aunque cargue rápido