Hablemos
Todos los textos
ux ui frontend accesibilidad testing 22 min

Estados de UI: cómo diseñar más allá de isLoading

Autor
Publicado
28 de julio de 2026

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.

iniciar

datos

cero resultados

falla

actualizar

datos nuevos

falla recuperable

dependencia falla

conexión perdida

reintentar

reintentar

reintentar

conexión vuelve

idle

loading

ready

empty

error

refreshing

stale

partial

offline

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íoQué significaRespuesta de UI
Primera vezTodavía no existe contenidoExplicar valor y ofrecer creación
Sin resultadosLos filtros no encontraron coincidenciasMostrar filtros activos y limpiar
Estado completadoLa ausencia es el resultado deseadoConfirmar 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:

  1. ¿qué no se completó?
  2. ¿qué se conservó?
  3. ¿puede resolverlo la persona?
  4. ¿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:

TipoEjemploTratamiento
Campo corregibleFecha incompletaMensaje junto al campo, conservar valor
Operación recuperableTimeoutReintento; conservar contexto
Resultado parcial18 de 20 importadosConservar éxitos; reparar pendientes
Servicio no disponibleAPI caídaEstado persistente y alternativa
Error terminalRecurso eliminadoExplicar situación y salida segura
PermisoRol insuficienteExplicar 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.

Ejemplo oficial de GOV.UK con un resumen de error, mensaje junto al campo y valores de día y mes preservados
El patrón conserva lo que la persona ya escribió, enlaza el resumen con el campo y explica la corrección. Captura del ejemplo oficial de GOV.UK Design System.

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:

  1. no existe contenido local;
  2. existe caché de sólo lectura;
  3. 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ébilOperativo
Cargando…Cargando pedidos de julio…
ErrorNo pudimos guardar el pedido. Tus cambios siguen aquí.
Sin datosNo hay coincidencias con “ACME”. Limpia la búsqueda.
OfflineSin conexión. Mostramos datos guardados a las 10:42.
ÉxitoPedido P-104 creado. Ya puedes enviarlo a revisión.
DeshabilitadoPublica 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;
  • Empty no 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

EstadoContenido conservadoAcciónSemántica
loadingNingunoCancelar si tardaaria-busy, texto visible
readyTablaFiltrar, abrir, refrescarRegión nombrada
refreshingTablaSeguir usandoaria-busy="true", status cortés
empty:first-useContextoCrear pedidoHeading + CTA
empty:no-resultsFiltrosLimpiar o ajustarStatus con conteo cero
partialFilas válidasReparar pendientesMensaje inline
staleÚltima versiónActualizarTimestamp visible
offlineCaché, si existeVer pendientesEstado persistente
blockedBorrador, si existeCambiar condiciónExplicación específica
errorContexto y entradaReintentar o salirAlert 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;
  • empty ofrece 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:

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

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.

Devolvámosle tiempo a su equipo.

Si alguna operación de su organización le está costando horas que podrían invertirse mejor, conversémoslo.