Sustituyendo las banderas de carga en Angular con una unión de estados asíncronos discriminados
Modela las operaciones asíncronas de Angular como una unión tipada, deriva valores booleanos con señales calculadas y gestiona cada transición de estado mediante un único operador RxJS.
Fuentes: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular
Casi todos los componentes de Angular que llaman a una API terminan rastreando su progreso con un puñado de booleanos: uno para indicar carga, otro para errores y uno por cada botón que puede estar ocupado. Cada indicador es razonable por sí solo, pero juntos permiten que el componente llegue a estados que nadie diseñó, y la plantilla se convierte en una tabla de verdad escrita a mano. Esta guía muestra cómo reemplazar esos indicadores por una única unión discriminada por operación asíncrona, derivar los booleanos que aún se necesitan con computed, trasladar todas las transiciones a un único operador RxJS y renderizar el resultado con @switch para que el verificador de tipos de la plantilla impida que se muestre cualquier cosa no permitida.
La versión con booleanos y por qué parece adecuada
Aquí hay una pantalla típica de pedidos que carga una lista al iniciarse y ofrece un botón de pago. Cuente los campos que existen únicamente para rastrear el progreso:
@Component({ /* ... */ })
export class OrdersComponent {
isLoading = false;
isRefreshing = false;
isCheckingOut = false;
isCancelling = false;
hasError = false;
errorMessage: string | null = null;
orders: Order[] = [];
ngOnInit(): void {
this.isLoading = true;
this.api.getOrders().subscribe({
next: (orders) => {
this.orders = orders;
this.isLoading = false;
},
error: (err) => {
this.hasError = true;
this.errorMessage = err.message;
this.isLoading = false;
},
});
}
checkout(): void {
this.isCheckingOut = true;
this.hasError = false;
this.api.checkout(this.orders).subscribe({
next: () => (this.isCheckingOut = false),
error: (err) => {
this.isCheckingOut = false;
this.hasError = true;
this.errorMessage = err.message;
},
});
}
}
La plantilla correspondiente debe combinar esas banderas para decidir qué renderizar, y el botón de pago debe estar al tanto de cada operación que pueda estar en ejecución:
@if (isLoading) { <app-spinner /> }
@if (!isLoading && hasError) { <app-error [message]="errorMessage" /> }
@if (!isLoading && !hasError && orders.length === 0) { <app-empty /> }
@if (!isLoading && !hasError && orders.length > 0) { <app-order-list [orders]="orders" /> }
<button [disabled]="isLoading || isRefreshing || isCheckingOut || isCancelling">
Checkout
</button>
Nada de esto está roto, y pasaría la mayoría de las revisiones. El problema es el crecimiento: cada nueva función añade otra bandera.
Por qué las banderas independientes no pueden describir estados exclusivos
Cinco booleanos generan 2 elevado a la potencia 5, es decir, 32 combinaciones posibles. La pantalla fue diseñada para aproximadamente cinco de ellas: inactiva, cargando, cargada, vacía y fallida. Las 27 restantes son accesibles en tiempo de ejecución, y el sistema de tipos no tiene forma de saber que son inválidas. Esa discrepancia se manifiesta de formas conocidas:
- Se permiten estados contradictorios. Nada impide que tanto
isLoadingcomohasErrorseantrue. Lo que ve el usuario depende entonces del orden de los bloques@if, y así es como un spinner y una barra de error aparecen juntos en la pantalla. - La plantilla se convierte en una tabla de verdad. Una condición como
!isLoading && !hasError && orders.length > 0representa una fila en una tabla que nunca se ha anotado por escrito, y cada nueva bandera multiplica el número de filas. - Las funcionalidades hacen que la clase crezca de forma lateral. La opción de actualización al arrastrar agrega
isRefreshing; una cancelación optimista añadeisCancellingy probablementecancellingId. El comportamiento real del componente queda oculto bajo toda esta gestión adicional.
isLoading === false && hasError === true en lugar de “el usuario ve un mensaje de error”.La causa subyacente es un error de modelado. Las banderas son independientes entre sí, pero los estados que deben describir son mutuamente excluyentes. Cuando el tipo no coincide con el dominio, surgen errores. Al cambiar el tipo, la mayoría de estos síntomas desaparecen.
Paso 1: representar el estado como un único valor
Una unión discriminada captura directamente la exclusividad. Cada variante lleva un literal status, y los datos de carga solo existen en las variantes donde tienen sentido:
import { HttpErrorResponse } from '@angular/common/http';
export type AsyncState<T, E = HttpErrorResponse> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'success'; readonly data: T }
| { readonly status: 'error'; readonly error: E };
Solo un status puede existir a la vez, y el data solo está presente cuando el estado es 'success'. Lo que antes solo se infería a partir de los valores booleanos, ahora lo exige el compilador.
Las funciones constructoras pequeñas mantienen legibles los puntos de llamada y evitan tener que escribir literalmente cadenas en todas partes. Un predicado como isLoading resulta útil para valores derivados posteriormente:
export const idle = () => ({ status: 'idle' }) as const;
export const loading = () => ({ status: 'loading' }) as const;
export const success = <T>(data: T) => ({ status: 'success', data }) as const;
export const failure = <E>(error: E) => ({ status: 'error', error }) as const;
export const isLoading = (state: AsyncState<unknown, unknown>): boolean =>
state.status === 'loading';
Las afirmaciones as const garantizan que cada constructor devuelva un tipo de status literal y no una cadena ampliada, lo que permite asignar estos objetos a la unión.
Por qué una interfaz con campos nulos no es suficiente
Un intento común inicial se ve así:
interface LoadState<T> {
loading: boolean;
data: T | null;
error: HttpErrorResponse | null;
}
Se trata del mismo conjunto de flags independientes envueltos en un tipo nombrado. Nada impide que exista { loading: true, data: [...], error: {...} }, por lo que cada consumidor sigue teniendo que adivinar qué campo es el autoritativo. El discriminante es el ingrediente esencial; sin él solo se ha reubicado el problema en lugar de resolverlo.
Paso 2: un estado por operación asíncrona
Asigne a cada operación su propio AsyncState, en lugar de un par de booleanos por cada situación posible:
readonly orders = signal<AsyncState<Order[]>>(idle());
readonly checkout = signal<AsyncState<Receipt>>(idle());
El número de campos de estado ahora coincide con el número de operaciones que pueden ejecutarse simultáneamente, lo cual representa la cuenta exacta, en lugar de tener un flag por cada forma en que algo podría fallar.
Paso 3: derive los booleanos en lugar de almacenarlos
Los templates aún necesitan valores booleanos. [disabled] requiere uno, y “¿está algo ocupado?” es una pregunta de sí o no. Eso está perfectamente bien: los valores booleanos funcionan bien como resultados derivados, pero no tanto como fuente de verdad.
readonly isBusy = computed(() =>
[this.orders(), this.checkout()].some(isLoading),
);
readonly canCheckout = computed(
() => this.orders().status === 'success' && this.checkout().status !== 'loading',
);
Dado que estos valores se calculan a partir de los estados subyacentes, no pueden desviarse. Al agregar o eliminar una operación, las banderas derivadas se actualizan automáticamente; no hay nada que reiniciar ni olvidar.
Paso 4: centralizar las transiciones en un operador RxJS
Definir manualmente isLoading = true antes de una solicitud y false en dos callbacks es la causa principal de los errores con las banderas. En cambio, un operador personalizado puede gestionar todas las transiciones:
import { OperatorFunction, of } from 'rxjs';
import { catchError, map, startWith } from 'rxjs/operators';
export function toAsyncState<T, E = HttpErrorResponse>(): OperatorFunction<T, AsyncState<T, E>> {
return (source$) =>
source$.pipe(
map((data) => success(data)),
catchError((error: E) => of(failure(error))),
startWith(loading()),
);
}
El cuerpo consta de tres operadores, y cada uno ofrece una garantía:
mapenvuelve cada valor emitido en un estadosuccess.
catchError convierte un error en un valor failure, de modo que el flujo en sí mismo nunca genera errores. Esto mantiene activos a consumidores como toSignal, rxMethod y el tubo async después de una solicitud fallida.startWith emite loading de forma síncrona al suscribirse, por lo que la interfaz de usuario nunca muestra un estado idle antes de que comience la solicitud.Y como ya no quedan flags, no hay nada que olvidar restablecer.
Para los datos obtenidos cuando se crea el componente, las partes se combinan en una sola declaración:
readonly orders = toSignal(
this.api.getOrders().pipe(toAsyncState()),
{ requireSync: true },
);
Usar requireSync: true es seguro aquí específicamente gracias a startWith. El observable emite de forma síncrona al suscribirse, por lo que la señal mantiene un valor de la primera detección de cambios y no es necesario inventar un initialValue. Si se elimina startWith, toSignal lanzará un error en su lugar.
Paso 5: comandos activados por el usuario en un almacén de señales
Cargar los datos al inicializar es el caso más sencillo. Comandos como pagar, cancelar o intentar de nuevo son activados por el usuario y requieren reglas de concurrencia. Con NgRx Signal Store, el operador se integra directamente en rxMethod, y la misma estructura puede aplicarse a un servicio normal si no se utiliza NgRx:
interface OrdersState {
orders: AsyncState<Order[]>;
checkout: AsyncState<Receipt>;
}
const initialState: OrdersState = { orders: idle(), checkout: idle() };
export const OrdersStore = signalStore(
withState(initialState),
withComputed(({ orders, checkout }) => ({
isBusy: computed(() => [orders(), checkout()].some(isLoading)),
canCheckout: computed(
() => orders().status === 'success' && checkout().status !== 'loading',
),
})),
withMethods((store, api = inject(OrdersApi)) => ({
loadOrders: rxMethod<void>(
pipe(
switchMap(() => api.getOrders().pipe(toAsyncState())),
tap((orders) => patchState(store, { orders })),
),
),
checkout: rxMethod<Order[]>(
pipe(
exhaustMap((orders) => api.checkout(orders).pipe(toAsyncState())),
tap((checkout) => patchState(store, { checkout })),
),
),
})),
withHooks({ onInit: (store) => store.loadOrders() }),
);
Hay varios aspectos que merecen atención. El estado de la tienda almacena un AsyncState por operación. withComputed define las banderas derivadas en un único lugar. loadOrders utiliza switchMap, por lo que una nueva carga cancela a la anterior obsoleta. Además, checkout emplea exhaustMap, el cual ignora nuevos disparadores mientras ya se está ejecutando un proceso de pago.
Esa última elección reemplaza la protección que requería la versión booleana, algo como if (this.isCheckingOut) return;, con el fin de evitar envíos duplicados. Dichas protecciones suelen desaparecer durante las refactorizaciones porque su propósito no es evidente. En este caso, la regla de concurrencia se encuentra dentro del pipeline justo al lado de la solicitud que protege.
Paso 6: un template que refleja la máquina de estados
Dado que solo hay un estado por el cual cambiar, el template enumera cada estado visual exactamente una vez:
@let orders = store.orders();
@switch (orders.status) {
@case ('loading') {
<app-spinner />
}
@case ('error') {
<app-error [error]="orders.error" (retry)="store.loadOrders()" />
}
@case ('success') {
@if (orders.data.length) {
<app-order-list [orders]="orders.data" />
} @else {
<app-empty />
}
}
}
<button [disabled]="!store.canCheckout()" (click)="store.checkout(orders.data)">
Checkout
</button>
En comparación con los cuatro bloques @if superpuestos del principio, ya no quedan combinaciones sobre las que razonar. Dentro del caso 'success', orders.data está disponible y tiene el tipo correcto; dentro de 'loading' no existe, y el verificador de tipos del template rechaza cualquier intento de usarlo.
La declaración @let realiza una función importante. TypeScript no puede determinar el tipo de store.orders().status entre llamadas separadas, ya que, para el compilador, cada llamada a store.orders() podría devolver un objeto diferente. Al leer la señal una vez en una variable local se obtiene una referencia estable para restringir el tipo, al igual que ocurre con const en TypeScript normal. Sin esto se tendría que recurrir a conversiones de tipo o a getters auxiliares.
Existe una advertencia relacionada con el botón de pago en este fragmento: se encuentra fuera de @switch, por lo que orders.data no se restringe allí, y la verificación estricta de tipos en las plantillas lo marcará como problema. Es necesario mover el botón a la rama 'success' o proporcionar al store un método de pago que lea directamente los pedidos actuales.
Mantener los datos existentes durante una actualización
Hacer que la pantalla se vacíe cada vez que el usuario actualiza la página genera una mala experiencia de uso. Con flags, mantener los datos antiguos visibles resulta sorprendentemente frágil: una condición como isLoading && orders.length > 0 funciona hasta que alguien elimina los datos de orders antes de realizar la solicitud. Con un union, simplemente se trata de otra variante que conserva los datos anteriores:
export type AsyncState<T, E = HttpErrorResponse> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'reloading'; readonly data: T }
| { readonly status: 'success'; readonly data: T }
| { readonly status: 'error'; readonly error: E };
El operador puede aceptar el estado anterior y elegir la variante de inicio correspondiente:
export function toAsyncState<T, E = HttpErrorResponse>(
previous?: AsyncState<T, E>,
): OperatorFunction<T, AsyncState<T, E>> {
const initial =
previous?.status === 'success' ? reloading(previous.data) : loading();
return (source$) =>
source$.pipe(
map((data) => success(data)),
catchError((error: E) => of(failure(error))),
startWith(initial),
);
}
Si el estado anterior fue de éxito, la transmisión comienza con reloading y mantiene los datos antiguos; de lo contrario, comienza con loading. Tenga en cuenta que el fragmento asume la existencia de un constructor reloading() además de los demás, definido de la misma manera que success(). En el template, una rama @case ('reloading') puede mostrar la lista existente con una barra de progreso delgada, reservando el spinner a pantalla completa para la primera carga. “Tenemos datos y estamos obteniendo más” es un estado sencillo de expresar como una variante, pero que también genera errores constantemente al manejar dos booleanos.
¿Qué pasa con un servicio global de carga?
Muchas aplicaciones cuentan las solicitudes en curso mediante un interceptor HTTP para mostrar una barra de progreso global. Ese es un uso razonable. Sin embargo, no constituye una base adecuada para la interfaz de usuario de los componentes: un contador no puede indicar qué solicitud está pendiente, las consultas en segundo plano y el tráfico de análisis también lo activan, y cada pantalla termina con un indicador idéntico.
Si necesita ambos, obtenga el indicador global a partir del estado local, por ejemplo mediante una función computed basada en el valor isBusy de cada almacén, y no al revés. El estado local nunca debe depender de un contador que no le pertenezca. En cuanto al manejo específico de fallos, nuestro artículo sobre diseño para solicitudes fallidas en Angular aborda los interceptores y las estrategias de reintentos.
Dónde encajan resource() y httpResource()
Las API resource() y httpResource() de Angular exponen una señal status() con valores como 'loading', 'reloading', 'resolved' y 'error'. El framework adoptó la misma idea: un estado diferenciado en lugar de una colección de indicadores. Estas API han evolucionado a lo largo de las últimas versiones de Angular, por lo que consulte la documentación actual para conocer los valores exactos de estado en su versión.
Cuando sus datos provienen de una consulta impulsada por entradas de señal, como un ID, un filtro o un número de página, utilice primero resource(). La unión AsyncState cubre los casos en los que no es aplicable: comandos como POST y DELETE, bases de código que aún se basan en gran medida en RxJS, y estados específicos del dominio que el framework no modela. El modelo mental es idéntico en ambos casos: un solo estado, no varios indicadores.
Cuándo un booleano sigue siendo la herramienta adecuada
Una pantalla con una sola solicitud, un único indicador de carga y sin manejo de errores funciona bien con isLoading. Envolverlo en una unión sería solo por formalidad.
Una regla práctica: el segundo booleano es la señal para refactorizar. Tan pronto como estés a punto de agregar isSaving junto a isLoading, cambia al uso de una unión. Esto implica un archivo de tipo y un operador adicionales.
Puntos clave
- La carga, el éxito y el error son mutuamente excluyentes, por lo que deben modelarse como una unión discriminada en lugar de flags independientes.
- Utiliza un
AsyncStatepor cada operación asíncrona, no una flag por cada modo de fallo. - Trata los booleanos como modelos de lectura derivados creados con
computed, nunca como estado almacenado.
toAsyncState() gestiona todas las transiciones y mantiene activos los flujos al convertir errores en valores.switchMap, exhaustMap) en lugar de mecanismos de control manuales.@let con @switch para que la plantilla siga el modelo de máquina de estados y el verificador de tipos lo haga cumplir.El componente presentado al principio de esta guía contaba con siete campos para el registro; la versión refactorizada tiene dos, y ambos representan estados reales. La diferencia no es estilística. Un tipo puede describir 32 estados, mientras que el otro describe exactamente los cinco para los cuales se diseñó la interfaz de usuario.
Lecturas relacionadas
- Desentrañando un Angular AuthService: Gateway, Store, Facade y Ports — Dividir un Angular AuthService sobrecargado en un gateway, un almacén de señales, un puerto de almacenamiento y una fachada, con actualización de tokens de un solo uso, un interceptor de ámbito y guardias probables.
- Cómo Angular Dependency Injection resuelve servicios con inject() — Comprender cómo Angular DI suministra servicios a través de inject(), providers e inyectores jerárquicos, y cómo delimitar instancias y evitar errores por proveedor faltante.