La mayoría de las interfaces se diseña con datos perfectos, red rápida y permisos completos.
Producción tiene otros planes.
La petición tarda, la respuesta llega vacía, un servicio secundario falla, la conexión desaparece durante un guardado, el token caduca o una actualización devuelve información menos reciente que la que ya estaba en pantalla.
Entonces aparece el repertorio habitual:
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [data, setData] = useState<Order[]>();
Parece suficiente hasta que isLoading, hasError y data son verdaderos al mismo tiempo. ¿Mostramos spinner, error o los datos que todavía sirven?
El problema no es React ni el spinner. El problema es que el estado real del producto no tiene un contrato.
Un componente no está terminado cuando renderiza datos. Está terminado cuando todos sus estados están nombrados, son alcanzables, recuperables y comprobables.
Esta guía convierte esa idea en tipos, microcopy, semántica accesible y pruebas que un equipo puede usar.
Dos familias de estados que no conviene mezclar
“Estado de UI” puede referirse a dos capas diferentes.
Estados de interacción
Describen la relación inmediata entre una persona y un control:
- enabled;
- hover;
- focus;
- pressed;
- selected;
- dragged;
- disabled.
Material Design 3 recomienda aplicarlos consistentemente y usar más de un indicador visual cuando sea necesario. Un borde de foco no sustituye el estado de los datos; sólo comunica dónde actuará el teclado.
Estados de vista y operación
Describen qué sabe o está haciendo el sistema:
- carga inicial;
- refresco;
- datos disponibles;
- resultado vacío;
- información parcial;
- datos desactualizados;
- error recuperable;
- error terminal;
- offline;
- autenticación o permiso insuficiente;
- límite de uso.
Mezclar ambas familias conduce a componentes que usan disabled para explicar cualquier problema. Un botón deshabilitado no puede comunicar por sí solo si falta un dato, un permiso, conexión o capacidad del servicio.
Esta entrada se concentra en la segunda familia sin olvidar que cada acción sigue necesitando hover, focus, pressed y feedback.
El mapa mínimo de una vista asíncrona
No toda aplicación necesita doce estados visualmente distintos, pero sí necesita decidir cuáles pueden ocurrir.
El diagrama obliga a responder preguntas que un mockup feliz oculta:
- ¿durante un refresco desaparecen los datos anteriores?
- ¿un resultado vacío es esperado o es una falla?
- ¿el error destruye trabajo válido?
- ¿qué transición provoca un reintento?
- ¿qué cambia cuando vuelve la conexión?
El objetivo no es construir una máquina de estados para cada badge. Es evitar que el comportamiento dependa de combinaciones accidentales.
De booleanos sueltos a estados imposibles de confundir
Este modelo permite combinaciones contradictorias:
type WeakState<T> = {
isLoading: boolean;
hasError: boolean;
isEmpty: boolean;
data?: T;
};
Con tres booleanos ya existen ocho combinaciones. Varias carecen de sentido y TypeScript las acepta.
Una unión discriminada convierte cada situación válida en un caso explícito:
type Problem = {
code: string;
message: string;
retryable: boolean;
};
type ViewState<T> =
| { kind: 'idle' }
| { kind: 'loading'; startedAt: number }
| {
kind: 'ready';
data: T;
freshness: 'fresh' | 'stale';
activity: 'idle' | 'refreshing';
}
| { kind: 'empty'; reason: 'first-use' | 'no-results' }
| { kind: 'partial'; data: T; problem: Problem }
| { kind: 'offline'; cached?: T; since?: number }
| {
kind: 'blocked';
reason: 'unauthenticated' | 'forbidden' | 'rate-limited';
retryAt?: number;
}
| { kind: 'error'; problem: Problem };
Ahora loading + error + empty no puede existir. ready + refreshing sí puede, porque es una situación útil: hay contenido visible mientras llega una versión nueva.
El render también puede exigir exhaustividad:
function OrdersView({ state }: { state: ViewState<Order[]> }) {
switch (state.kind) {
case 'idle':
case 'loading':
return <OrdersSkeleton />;
case 'empty':
return <OrdersEmpty reason={state.reason} />;
case 'ready':
return <OrdersTable data={state.data} refreshing={state.activity === 'refreshing'} />;
case 'partial':
return <OrdersPartial data={state.data} problem={state.problem} />;
case 'offline':
return <OrdersOffline cached={state.cached} />;
case 'blocked':
return <OrdersBlocked reason={state.reason} retryAt={state.retryAt} />;
case 'error':
return <OrdersError problem={state.problem} />;
default:
return assertNever(state);
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
Cuando alguien añade timed-out, el compilador señala los renders incompletos. El tipo se vuelve una pequeña especificación ejecutable.
Loading inicial y refreshing no son lo mismo
Una carga inicial todavía no tiene contenido que proteger. Un refresco sí.
Carga inicial
Usa skeleton cuando:
- conoces razonablemente la estructura que aparecerá;
- la espera durará más que un cambio casi instantáneo;
- el placeholder ayuda a conservar el layout.
Usa un indicador pequeño cuando:
- el área es compacta;
- la forma del resultado es impredecible;
- la operación bloquea sólo un control.
Usa progreso determinado cuando puedes medirlo. Importando 670 de 1,000 clientes es más informativo que un spinner.
El patrón de loading de Carbon distingue skeleton, indicadores y carga progresiva. También desaconseja convertir acciones como botones o menús en skeletons: el placeholder debe representar contenido esperado, no decorar toda la pantalla.
Refresco
Durante un refresco:
- conserva los datos;
- indica actualización cerca de la región afectada;
- evita bloquear lectura o navegación;
- reemplaza sólo cuando la nueva respuesta sea válida;
- si falla, conserva la versión anterior y marca su antigüedad.
Pedidos
Actualizando…
[tabla todavía visible]
Vaciar la página para mostrar un spinner crea parpadeo, pierde posición de lectura y hace que una operación de segundo plano parezca una carga inicial.
Una regla simple:
sin datos utilizables → loading
con datos utilizables → refreshing
Empty no significa una sola cosa
data.length === 0 describe una cantidad. No explica la intención.
Hay al menos tres vacíos distintos:
| Vacío | Qué significa | Respuesta de UI |
|---|---|---|
| Primera vez | Todavía no existe contenido | Explicar valor y ofrecer creación |
| Sin resultados | Los filtros no encontraron coincidencias | Mostrar filtros activos y limpiar |
| Estado completado | La ausencia es el resultado deseado | Confirmar logro sin CTA artificial |
Ejemplos:
Todavía no hay proyectos
Crea el primero para organizar tareas, responsables y fechas.
[Crear proyecto]
No encontramos facturas “ACME” en julio
[Limpiar búsqueda] [Cambiar fechas]
Todo al día
No tienes pagos pendientes.
Atlassian define su empty state como una vista sin datos que explica qué puede hacer la persona después. La segunda mitad de la definición importa: una pantalla vacía no debe obligar a deducir por qué está vacía.
No muestres No hay datos antes de resolver la petición. Ese destello comunica una conclusión falsa.
Error no es un componente: es una estrategia de recuperación
Un error útil responde:
- ¿qué no se completó?
- ¿qué se conservó?
- ¿puede resolverlo la persona?
- ¿qué acción concreta sigue?
No pudimos actualizar los pedidos
Sigues viendo los datos guardados a las 10:42.
[Reintentar actualización]
No todos los fallos merecen el mismo patrón:
| Tipo | Ejemplo | Tratamiento |
|---|---|---|
| Campo corregible | Fecha incompleta | Mensaje junto al campo, conservar valor |
| Operación recuperable | Timeout | Reintento; conservar contexto |
| Resultado parcial | 18 de 20 importados | Conservar éxitos; reparar pendientes |
| Servicio no disponible | API caída | Estado persistente y alternativa |
| Error terminal | Recurso eliminado | Explicar situación y salida segura |
| Permiso | Rol insuficiente | Explicar autoridad requerida; no “reintentar” |
El sistema de diseño de GOV.UK insiste en conservar los valores introducidos, usar errores específicos y repetir el mismo mensaje en el resumen y junto al campo.
Algo salió mal puede ser honesto, pero casi nunca es suficiente. Si el sistema no conoce la causa, todavía puede indicar qué tarea no terminó, si los datos siguen seguros y cuándo conviene reintentar.
Partial y stale: los estados que más trabajo salvan
Muchos productos tratan una respuesta incompleta como fracaso total.
Imagina un dashboard con ventas, inventario y devoluciones. Si devoluciones falla, borrar ventas e inventario reduce una respuesta parcialmente útil a una pantalla inútil.
Resumen de hoy
Ventas $182,400
Inventario 94% disponible
Devoluciones No disponible · Reintentar
partial conserva los datos válidos y localiza el fallo.
stale conserva una versión anterior cuya vigencia importa:
Mostrando inventario de las 10:42
No pudimos obtener una versión más reciente.
[Actualizar]
No basta un punto amarillo sin texto. Expón:
- momento de actualización;
- alcance de lo desactualizado;
- impacto en la decisión;
- forma de recuperar.
Para una nota editorial, cinco minutos de antigüedad pueden ser irrelevantes. Para inventario o precios, pueden cambiar una decisión. La frescura pertenece al dominio, no a una constante global.
Offline no debería parecer un error genérico
Sin conexión hay tres escenarios:
- no existe contenido local;
- existe caché de sólo lectura;
- existen cambios locales pendientes de sincronización.
Cada uno necesita un contrato diferente:
Sin conexión
Necesitamos internet para cargar tus pedidos por primera vez.
[Reintentar]
Trabajando sin conexión
Mostramos datos guardados a las 10:42.
2 cambios pendientes
Se sincronizarán cuando vuelva la conexión.
[Ver cambios]
No prometas sincronización si la arquitectura sólo vuelve a intentar mientras la pestaña permanece abierta. La claridad operacional debe corresponder a la implementación real.
Una página fallback, como explica web.dev, puede evitar el error genérico del navegador; una experiencia offline real requiere además almacenamiento, conflictos y estado de sincronización.
Auth, permisos y rate limit no son “algo salió mal”
Estos estados se parecen técnicamente porque la petición no entrega datos. Para la persona significan cosas distintas.
No autenticado
Tu sesión terminó
Inicia sesión para continuar. Conservamos este borrador en este dispositivo.
[Iniciar sesión]
Sin permiso
No tienes permiso para publicar
Un editor puede publicar este borrador.
[Copiar enlace] [Volver a borradores]
Límite alcanzado
Alcanzaste 100 exportaciones este mes
Podrás exportar de nuevo el 1 de agosto.
[Ver uso]
Reintentar una petición 403 sin cambiar autoridad no recupera nada. Un CTA debe modificar la condición que bloquea la tarea o conducir a una salida útil.
El contrato visual también debe ser accesible
WCAG 2.2 define un mensaje de estado como información sobre resultado, espera, progreso o error que aparece sin cambiar el contexto. El criterio 4.1.3 Status Messages pide que estos cambios puedan ser determinados por software sin recibir foco.
role="status" para información no urgente
<div role="status" aria-atomic="true">
18 resultados encontrados
</div>
status funciona como una región viva cortés. El lector de pantalla espera un momento apropiado para anunciarla y no necesita mover el foco.
Úsalo para:
- guardado completado;
- resultados actualizados;
- carrito modificado;
- espera o finalización no crítica.
role="alert" para errores urgentes
<div role="alert">
No se guardó el pago. Revisa la conexión antes de cerrar.
</div>
alert es asertivo e interrumpe. No lo uses para Guardado, cada tecla, cambios de porcentaje o información rutinaria. La técnica ARIA19 documenta su uso para errores inyectados dinámicamente.
aria-busy para una región en actualización
<section aria-labelledby="orders-title" aria-busy="true">
<h2 id="orders-title">Pedidos</h2>
<!-- se conservan los pedidos actuales -->
</section>
Cuando termine:
<section aria-labelledby="orders-title" aria-busy="false">
aria-busy ayuda a indicar que la región está siendo modificada. No reemplaza el texto visible Actualizando pedidos.
Progreso determinado
<div
role="progressbar"
aria-label="Importando clientes"
aria-valuemin="0"
aria-valuemax="1000"
aria-valuenow="670"
>
670 de 1,000 clientes
</div>
No anuncies cada uno por ciento. La técnica ARIA25 recomienda comunicar progreso visual y programáticamente; el documento de WCAG advierte que demasiadas regiones vivas pueden volver la aplicación excesivamente habladora.
Una política práctica puede anunciar hitos cada 10%, cambio de fase, finalización y error.
No secuestres el foco para demostrar que pasó algo
Cuando termina una búsqueda, el foco puede permanecer en el botón o campo que la inició mientras role="status" anuncia el resultado. Moverlo a la lista impide repetir o corregir la búsqueda.
Sí puede corresponder mover foco cuando:
- se abre un diálogo;
- la navegación produce una nueva vista;
- un formulario enviado vuelve con un resumen de errores;
- continuar sin atender el contenido sería peligroso.
El patrón de validación de GOV.UK mueve foco al resumen después del envío y conserva los campos. Eso es diferente de interrumpir al usuario con cada validación mientras escribe.
La pregunta no es “¿se anunció?”. Es “¿la persona conserva orientación y puede continuar?”.
Microcopy: estado, consecuencia y salida
Una fórmula útil:
qué ocurre + qué conserva el sistema + qué puede hacer la persona
| Débil | Operativo |
|---|---|
| Cargando… | Cargando pedidos de julio… |
| Error | No pudimos guardar el pedido. Tus cambios siguen aquí. |
| Sin datos | No hay coincidencias con “ACME”. Limpia la búsqueda. |
| Offline | Sin conexión. Mostramos datos guardados a las 10:42. |
| Éxito | Pedido P-104 creado. Ya puedes enviarlo a revisión. |
| Deshabilitado | Publica después de resolver 2 campos obligatorios. |
Evita:
- códigos técnicos sin traducción;
- humor cuando hay pérdida o bloqueo;
- culpar a la persona;
- pedir reintento infinito;
- prometer que “nada se perdió” si no puedes demostrarlo;
- indicar sólo un color o icono.
Caso completo: una tabla de pedidos
Implementación frágil
if (isLoading) return <Spinner />;
if (error) return <Error />;
if (!orders?.length) return <Empty />;
return <Table orders={orders} />;
Problemas:
- un refresco borra la tabla;
Emptyno distingue primera vez de filtro sin resultados;- cualquier error elimina datos anteriores;
- permiso y offline parecen fallos genéricos;
- nada anuncia cambios sin foco;
- no existe resultado parcial.
Contrato propuesto
| Estado | Contenido conservado | Acción | Semántica |
|---|---|---|---|
loading | Ninguno | Cancelar si tarda | aria-busy, texto visible |
ready | Tabla | Filtrar, abrir, refrescar | Región nombrada |
refreshing | Tabla | Seguir usando | aria-busy="true", status cortés |
empty:first-use | Contexto | Crear pedido | Heading + CTA |
empty:no-results | Filtros | Limpiar o ajustar | Status con conteo cero |
partial | Filas válidas | Reparar pendientes | Mensaje inline |
stale | Última versión | Actualizar | Timestamp visible |
offline | Caché, si existe | Ver pendientes | Estado persistente |
blocked | Borrador, si existe | Cambiar condición | Explicación específica |
error | Contexto y entrada | Reintentar o salir | Alert sólo si es urgente |
Flujo de refresco
ready(fresh)
→ la persona pulsa Actualizar
→ ready(refreshing), tabla visible
→ respuesta válida
→ ready(fresh), timestamp nuevo
Si falla:
ready(refreshing)
→ timeout
→ ready(stale), tabla visible + explicación + reintento
Ésta es la diferencia entre “manejar un error” y proteger una tarea.
Haz cada estado alcanzable en desarrollo
Los estados que sólo dependen de fallos reales casi nunca se revisan.
Añade escenarios deterministas en desarrollo o Storybook:
/pedidos?scenario=loading
/pedidos?scenario=empty-first-use
/pedidos?scenario=empty-filter
/pedidos?scenario=partial
/pedidos?scenario=stale
/pedidos?scenario=offline-cached
/pedidos?scenario=forbidden
/pedidos?scenario=error-retryable
El selector debe existir únicamente fuera de producción o detrás de una capacidad interna. Su beneficio es enorme:
- diseño puede revisar copy real;
- QA reproduce sin apagar servicios;
- accesibilidad navega cada transición;
- screenshots detectan regresiones;
- una PR demuestra cobertura.
No hagas que el estado dependa de ralentizar DevTools y cruzar los dedos.
Pruebas que verifican comportamiento, no sólo screenshots
Una prueba útil comprueba el contrato:
test('un refresco conserva la tabla y comunica el resultado', async ({ page }) => {
await page.goto('/pedidos?scenario=refreshing');
const region = page.getByRole('region', { name: 'Pedidos' });
await expect(region).toHaveAttribute('aria-busy', 'true');
await expect(page.getByRole('table')).toBeVisible();
await expect(page.getByText('Actualizando pedidos…')).toBeVisible();
await page.getByTestId('resolve-refresh').click();
await expect(region).toHaveAttribute('aria-busy', 'false');
await expect(page.getByRole('status')).toContainText('Pedidos actualizados');
await expect(page.getByRole('table')).toBeVisible();
});
También conviene verificar:
- el foco permanece donde corresponde;
- un error conserva inputs;
emptyofrece una acción pertinente;- offline distingue caché y cambios pendientes;
- permisos no muestran un reintento inútil;
- progreso tiene nombre, mínimo, máximo y valor;
role="alert"no aparece para actualizaciones rutinarias;- el teclado puede ejecutar recuperación.
Los snapshots visuales detectan cambios de pixels. Estas pruebas detectan cambios de significado.
State review: 20 minutos antes de aprobar una PR
1. Inventario
Lista estados posibles de datos, operación, conectividad, permisos y negocio.
2. Alcanzabilidad
Fuerza cada estado sin depender de servicios externos.
3. Contrato
Para cada uno responde:
- ¿qué ve?
- ¿qué conserva?
- ¿qué puede hacer?
- ¿qué se anuncia?
- ¿cómo sale?
4. Transiciones
Prueba inicio, resolución, error, reintento, cancelación y regreso de conexión.
5. Evidencia
Recorre teclado, lector de pantalla cuando el riesgo lo amerite y pruebas automatizadas.
Si el equipo no puede provocar un estado, tampoco puede afirmar que funciona.
Kit descargable
Publicamos un pequeño kit de estados de UI con:
ui-state-contract.ts: unión discriminada, constructores y helpers exhaustivos;ui-state-matrix.md: matriz para producto, diseño, frontend y QA;ui-states.spec.ts: plantilla Playwright para carga, refresco, vacío, error y accesibilidad.
El contrato es framework-agnostic. Puedes mapearlo a React, Vue, Svelte, Astro o una máquina de estados existente.
Checklist de entrega
- Carga inicial y refresco son estados diferentes.
- Los datos válidos no desaparecen durante una actualización.
- Primera vez, búsqueda sin resultados y tarea completada no comparten copy.
- Los resultados parciales conservan el trabajo correcto.
- Los datos stale muestran antigüedad e impacto.
- Offline distingue falta de caché, lectura local y cambios pendientes.
- Autenticación, permisos y límites explican condiciones diferentes.
- Los errores dicen qué falló, qué se conservó y cómo continuar.
- Los cambios dinámicos importantes son perceptibles sin mover foco.
-
role="alert"se reserva para información urgente. - El progreso no anuncia cada variación insignificante.
- Cada estado puede provocarse de manera determinista.
- Las pruebas recorren transiciones y recuperación.
- Añadir un estado nuevo obliga a revisar el render.
Preguntas frecuentes
¿Necesito una librería de state machines?
No siempre. Una unión discriminada y transiciones explícitas resuelven muchos componentes. Una máquina formal ayuda cuando existen concurrencia, cancelación, reanudación, pasos paralelos o muchas transiciones condicionadas.
¿Skeleton o spinner?
Skeleton para una estructura de contenido predecible durante la carga inicial; indicador inline para una operación compacta; progreso determinado cuando conoces avance. En un refresco, conserva el contenido y comunica actividad sin reemplazar toda la vista.
¿Debo mostrar todos estos estados como pantallas distintas?
No. El modelo debe distinguirlos aunque algunos compartan componentes. offline y stale pueden ser banners sobre contenido; partial puede localizarse en una sección; loading inicial sí puede ocupar la región.
¿aria-live arregla automáticamente la accesibilidad?
No. Una región viva puede anunciar el cambio, pero no corrige copy ambiguo, foco roto, controles sin nombre ni actualizaciones excesivas. Usa HTML semántico primero y prueba el comportamiento real.
¿Cuándo un error debe mover el foco?
Cuando la interacción produce un nuevo contexto que necesita atención, como un resumen tras enviar un formulario inválido. Una actualización de fondo normalmente debe anunciarse sin moverlo.
¿Un botón deshabilitado necesita explicación?
Si la acción es relevante y la persona puede habilitarla, explica el requisito. Si nunca aplica a su rol o contexto, mostrarla puede añadir ruido. No uses disabled como sustituto de permisos, errores o estado de red.
Fuentes originales
- States — Material Design 3
- Loading pattern — Carbon Design System
- Empty state — Atlassian Design System
- Error message — GOV.UK Design System
- Recover from validation errors — GOV.UK Design System
- WCAG 2.2: Status Messages — W3C
- ARIA19: alert y regiones vivas para errores — W3C
- ARIA25: progreso accesible — W3C
- WAI-ARIA 1.2 — W3C
- Create an offline fallback page — web.dev
El happy path demuestra que la pantalla puede verse bien. Los estados reales demuestran que el producto sabe acompañar a una persona cuando el mundo deja de cooperar.