Replacing Loading Flags in Angular with a Discriminated AsyncState Union
Model Angular async operations as one typed union, derive booleans with computed signals and drive every state transition through a single RxJS operator.
This article is published in English.
Sources: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular
Nearly every Angular component that calls an API ends up tracking its progress with a handful of booleans: one for loading, one for errors, one per button that can be busy. Each flag is reasonable on its own, but together they allow the component to reach states nobody designed, and the template turns into a hand-written truth table. This guide shows how to replace those flags with a single discriminated union per async operation, derive the booleans you still need with computed, move every transition into one small RxJS operator, and render the result with @switch so the template type-checker enforces what the UI is allowed to show.
The boolean version and why it seems fine
Here is a typical orders screen that loads a list on init and offers a checkout button. Count the fields that exist only to track progress:
@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;
},
});
}
}
The matching template has to combine those flags to decide what to render, and the checkout button has to know about every operation that could be running:
@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>
None of this is broken, and it would pass most reviews. The trouble is growth: every feature adds another flag.
Why independent flags cannot describe exclusive states
Five booleans produce 2 to the power of 5, or 32, possible combinations. The screen was designed for roughly five of them: idle, loading, loaded, empty and failed. The remaining 27 are reachable at runtime, and the type system has no way of knowing they are invalid. That mismatch shows up in familiar ways:
- Contradictory states are allowed. Nothing prevents
isLoadingandhasErrorfrom both beingtrue. What the user sees then depends on the order of the@ifblocks, which is how a spinner and an error banner end up on screen together. - The template becomes a truth table. A condition such as
!isLoading && !hasError && orders.length > 0is a row in a table that was never written down, and every new flag multiplies the number of rows. - Features grow the class sideways. Pull-to-refresh adds
isRefreshing; an optimistic cancel addsisCancellingand probablycancellingId. The actual behavior of the component gets buried under bookkeeping. - Tests check flags instead of behavior. Assertions end up as
isLoading === false && hasError === truerather than "the user sees an error message".
The underlying cause is a modeling error. The flags are independent of each other, but the states they are meant to describe are mutually exclusive. When the type does not match the domain, bugs follow. Change the type and most of these symptoms go away.
Step 1: represent the state as a single value
A discriminated union captures the exclusivity directly. Every variant carries a status literal, and payloads exist only on the variants where they make sense:
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 };
Only one status can hold at a time, and data is present only when the status is 'success'. What the booleans merely implied, the compiler now enforces.
Small constructor functions keep call sites readable and save you from typing string literals everywhere. A predicate such as isLoading is handy for derived values later:
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';
The as const assertions ensure each constructor returns a literal status type rather than a widened string, which is what allows these objects to be assigned to the union.
Why an interface with nullable fields is not enough
A common first attempt looks like this:
interface LoadState<T> {
loading: boolean;
data: T | null;
error: HttpErrorResponse | null;
}
It is the same set of independent flags wrapped in a named type. Nothing stops { loading: true, data: [...], error: {...} }, so every consumer still has to guess which field is authoritative. The discriminant is the essential ingredient. Without it you have relocated the problem rather than solved it.
Step 2: one state per async operation
Give each operation its own AsyncState, rather than a pair of booleans per concern:
readonly orders = signal<AsyncState<Order[]>>(idle());
readonly checkout = signal<AsyncState<Receipt>>(idle());
The number of state fields now matches the number of operations that can be in flight concurrently, which is the honest count, instead of one flag for every way something can go wrong.
Step 3: derive booleans instead of storing them
Templates still need booleans. [disabled] takes one, and "is anything busy?" is a yes-or-no question. That is perfectly fine: booleans work well as derived outputs and poorly as the source of truth.
readonly isBusy = computed(() =>
[this.orders(), this.checkout()].some(isLoading),
);
readonly canCheckout = computed(
() => this.orders().status === 'success' && this.checkout().status !== 'loading',
);
Because these values are computed from the underlying states, they cannot drift. Add or remove an operation and the derived flags follow automatically; there is nothing to reset and nothing to forget.
Step 4: centralize transitions in one RxJS operator
Hand-wiring isLoading = true before a request and false in two callbacks is where most flag bugs come from. A custom operator can own every transition instead:
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()),
);
}
The body is three operators, and each contributes a guarantee:
mapwraps each emitted value in asuccessstate.catchErrorconverts an error into afailurevalue, so the stream itself never errors. That keeps consumers such astoSignal,rxMethodand theasyncpipe alive after a failed request.startWithemitsloadingsynchronously on subscription, so the UI never shows anidleframe before the request starts.
And since there are no flags left, there is nothing to forget to reset.
For data fetched when the component is created, the pieces combine into a single declaration:
readonly orders = toSignal(
this.api.getOrders().pipe(toAsyncState()),
{ requireSync: true },
);
Using requireSync: true is safe here specifically because of startWith. The observable emits synchronously on subscription, so the signal holds a value from the first change detection pass and you do not need to invent an initialValue. Remove startWith and toSignal would throw instead.
Step 5: user-triggered commands in a signal store
Loading on init is the easy case. Commands such as checkout, cancel or retry are triggered by the user and need concurrency rules. With NgRx Signal Store the operator slots directly into rxMethod, and the same structure translates to a plain service if you do not use 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() }),
);
A few things are worth noticing. The store state holds one AsyncState per operation. withComputed defines the derived flags in one place. loadOrders uses switchMap, so a new load cancels a stale one. And checkout uses exhaustMap, which ignores new triggers while a checkout is already running.
That last choice replaces the guard the boolean version needed, something like if (this.isCheckingOut) return;, to prevent double submissions. Such guards tend to disappear during refactors because their purpose is not obvious. Here, the concurrency rule sits inside the pipeline right next to the request it protects.
Step 6: a template that mirrors the state machine
With a single status to switch on, the template lists each visual state exactly once:
@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>
Compared with the four overlapping @if blocks from the beginning, there are no combinations left to reason about. Inside the 'success' case, orders.data is available and correctly typed; inside 'loading' it does not exist, and the template type-checker rejects any attempt to use it.
The @let declaration is doing important work. TypeScript cannot narrow store.orders().status across separate calls, because as far as the compiler knows each call to store.orders() could return a different object. Reading the signal once into a local variable gives the union a stable reference to narrow, exactly like a const in ordinary TypeScript. Without it you would be back to type casts or helper getters.
One caveat applies to the checkout button in this snippet: it sits outside the @switch, so orders.data is not narrowed there, and strict template type-checking will flag it. Either move the button into the 'success' branch or give the store a checkout method that reads the current orders itself.
Keeping existing data during a refresh
Blanking the screen every time the user refreshes is a poor experience. With flags, keeping old data visible is surprisingly fragile: a condition like isLoading && orders.length > 0 works until someone clears orders before the request. With a union, it is just another variant that carries the previous data:
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 };
The operator can accept the previous state and choose its starting variant accordingly:
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),
);
}
If the previous state was a success, the stream starts with reloading and keeps the old data; otherwise it starts with loading. Note that the snippet assumes a reloading() constructor alongside the others, defined the same way as success(). In the template, a @case ('reloading') branch can render the existing list with a thin progress bar, reserving the full-screen spinner for the first load. "We have data and are fetching more" is a trivial state to express as a variant and a steady source of bugs as two booleans.
What about a global loading service?
Many apps count in-flight requests in an HTTP interceptor to drive a global progress bar. That is a reasonable use. It is a poor basis for component UI, though: a counter cannot say which request is pending, background polling and analytics traffic trigger it too, and every screen ends up with an identical indicator.
If you need both, derive the global indicator from local state, for example a computed over each store's isBusy, rather than the reverse. Local state should never depend on a counter it does not own. For handling failures specifically, our article on designing for failed requests in Angular covers interceptors and retry strategies.
Where resource() and httpResource() fit
Angular's resource() and httpResource() APIs expose a status() signal with values such as 'loading', 'reloading', 'resolved' and 'error'. The framework settled on the same idea: one discriminated status rather than a collection of flags. These APIs have evolved across recent Angular releases, so check the current documentation for the exact status values in your version.
When your data is a query driven by signal inputs, such as an ID, a filter or a page number, reach for resource() first. The AsyncState union covers what it does not: commands like POST and DELETE, codebases that are still largely RxJS-based, and domain-specific states the framework does not model. The mental model is identical either way: one status, not many flags.
When a boolean is still the right tool
A screen with a single fetch, a single spinner and no error handling is fine with isLoading. Wrapping that in a union would be ceremony for its own sake.
A practical rule: the second boolean is the signal to refactor. As soon as you are about to add isSaving next to isLoading, switch to the union. It costs one type file and one operator.
Key takeaways
- Loading, success and error are mutually exclusive, so model them as one discriminated union rather than independent flags.
- Use one
AsyncStateper async operation, not one flag per failure mode. - Treat booleans as derived read models built with
computed, never as stored state. - A single
toAsyncState()operator owns every transition and keeps streams alive by turning errors into values. - Express concurrency rules with the flattening operator (
switchMap,exhaustMap) instead of manual guards. - Combine
@letwith@switchso the template follows the state machine and the type-checker enforces it.
The component at the top of this guide carried seven fields of bookkeeping; the refactored version has two, and both represent real state. The difference is not stylistic. One type can describe 32 states, the other describes exactly the five the UI was built for.