CodeForge

Angular 21 de Cero a Experto / HTTP y datos

Estados de carga y error: cargando, error, datos

Teoría22 min20 XP

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:

lista-gastos.component.ts
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—.