Angular 21 de Cero a Experto / HTTP y datos
Estados de carga y error: cargando, error, datos
Una petición no es instantánea ni infalible: tarda, y a veces falla. Mostrar la pantalla en blanco mientras carga, o dejarla congelada si algo va mal, es mala experiencia. Toda petición tiene TRES estados —cargando, error, datos— y tu UI debe reflejar cada uno. Hoy aprendes el patrón de estados de carga con señales, para que "Mi Bolsillo" siempre le diga al usuario qué está pasando.
El telón, la red y el espectáculo
El patrón: un Observable, tres estados
La forma moderna es convertir el Observable de la petición en una señal con toSignal, y
derivar de ella los tres estados:
import { Component, inject, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, of, map, startWith } from 'rxjs';
import { ApiGastosService } from './api-gastos.service';
type Estado<T> =
| { estado: 'cargando' }
| { estado: 'error'; mensaje: string }
| { estado: 'ok'; datos: T };
@Component({
selector: 'app-lista-gastos',
template: `
@if (vm().estado === 'cargando') {
<p>Cargando tus gastos…</p>
} @else if (vm().estado === 'error') {
<p role="alert">Error: {{ vm().mensaje }}</p>
} @else {
<ul>
@for (g of vm().datos; track g.id) { <li>{{ g.nombre }}</li> }
</ul>
}
`,
})
export class ListaGastosComponent {
private api = inject(ApiGastosService);
// el Observable de la petición → señal con los tres estados
readonly vm = toSignal(
this.api.listar().pipe(
map((datos) => ({ estado: 'ok', datos }) as Estado<Gasto[]>),
startWith({ estado: 'cargando' } as Estado<Gasto[]>),
catchError((err) => of({ estado: 'error', mensaje: 'No se pudo cargar' } as Estado<Gasto[]>)),
),
{ initialValue: { estado: 'cargando' } as Estado<Gasto[]> },
);
}Fíjate en el patrón RxJS: startWith('cargando') emite el estado inicial de inmediato;
map envuelve los datos como estado 'ok' cuando llegan; catchError convierte un fallo en
estado 'error' (sin matar el stream). El resultado es una señal vm que el template lee para
mostrar el momento correcto. Ese Estado<T> es una discriminated union —la misma técnica
del SC-03 que hace imposibles los estados imposibles: nunca tendrás "cargando" y "error" a la
vez—.
Por qué una discriminated union y no tres booleanos
Pruébalo: la máquina de estados de una petición, en vivo
La lógica de los tres estados —empezar cargando, pasar a datos o a error— es pura y se puede ejecutar. Aquí, la máquina de estados de una petición simulada:
Observa la secuencia: SIEMPRE empieza en cargando, y desde ahí transita a ok o a error
—nunca ambos—. El switch del render es exhaustivo: cubre los tres casos, y TypeScript se
quejaría si olvidaras uno. Esa es la robustez de modelar el estado como una unión discriminada.
¿Por qué modelar el estado de una petición como una discriminated union en vez de tres booleanos (`cargando`, `error`, `datos`)?
Mini-reto
En el playground: 1) añade un estado 'vacio' para cuando la petición tiene éxito pero
devuelve una lista vacía (datos.length === 0); 2) ajusta cargar para emitirlo; 3) amplía el
render con el nuevo caso y comprueba que el switch sigue siendo exhaustivo. Piensa cómo se
vería el @else if (vm().estado === 'vacio') en el template.
Qué sigue
Ya muestras cada estado de una petición. Pero hay lógica que se repite en TODAS las peticiones: añadir el token de autenticación, registrar errores, reintentar. En vez de repetirla, la centralizas en un INTERCEPTOR. La próxima lección son los interceptores funcionales —el filtro por el que pasan todas tus peticiones—.