Loading isn't a boolean
isLoading: boolean isn’t wrong.
It’s just often too small.
For simple cases, a boolean is completely sufficient. A button gets clicked, a request runs, then it’s done. No existing data, no error state, no refresh, no parallel requests.
Then isLoading is pragmatic.
But a lot of frontend flows aren’t that simple.
And that’s when the pain starts.
Because a boolean can only express two things:
isLoading = true;isLoading = false;But a real asynchronous state has more than two meanings.
The problem
Section titled “The problem”A loading process isn’t a switch.
It’s a state space.
Typical states are:
idle— nothing has been requested yetloading— the first request is runningsuccess— data loaded successfullyerror— the request failedrefreshing— data exists, but is being updated in the background
With a boolean, all of that collapses into:
isLoading: boolean;The rest doesn’t disappear.
It just spreads out into more variables.
isLoading = false;data: Task[] | null = null;error: string | null = null;And now the real problem starts.
What does this state mean?
isLoading = false;data = null;error = null;Not loaded at all yet? Loaded successfully, but no data? Error reset, but data forgotten? Initial state after navigation? Intermediate state during a reload?
Nobody knows for sure.
The code has to guess.
And once code starts guessing at state, the architecture is leaking.

The hidden state
Section titled “The hidden state”The problem with isLoading is rarely the boolean itself.
The problem is the state that never got modeled.
isLoading: boolean;data: Task[] | null;error: string | null;These three fields together form one state.
But they act as if they were independent.
That creates combinations that make no business sense.
isLoading = true;data = null;error = 'Failed to load';Is it currently loading? Or did an error happen? Or both?
Another example:
isLoading = false;data = [];error = 'Failed to load';Is the empty list the result? Or is old data left over? Should the UI show the error or the list?
And the classic:
isLoading = true;data = existingTasks;error = null;Is this an initial load? Or a refresh with existing data?
For the UI, that’s a big difference.
On the initial load, you might show a big skeleton state.
On a refresh, you keep showing the existing data and maybe just a small spinner.
A boolean can’t express that difference.
Refreshing is the moment of truth
Section titled “Refreshing is the moment of truth”The difference between loading and refreshing shows especially well why isLoading is often too coarse.
type TasksState = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: Task[] } | { status: 'refreshing'; data: Task[] } | { status: 'error'; message: string };Now it’s clear:
{ status: 'loading';}means:
There’s no data yet. The first request is running.
And:
{ status: 'refreshing', data: tasks }means:
Data already exists. A new request is running in the background.
That’s not a cosmetic difference.
It affects the UI.
@if (state.status === 'loading') {<app-skeleton />} @if (state.status === 'refreshing') {<app-task-list [tasks]="state.data" /><app-small-spinner />}Without explicit state, this distinction often ends up in the template, in helper variables, or in ad hoc conditionals.
@if (isLoading && !data) {<app-skeleton />} @if (isLoading && data) {<app-task-list [tasks]="data" /><app-small-spinner />}That works.
But the meaning doesn’t live in the model.
It lives in a conditional.
And conditionals grow.
The alternative: a discriminated union
Section titled “The alternative: a discriminated union”In TypeScript, you can model a state space very cleanly as a discriminated union.
type LoadingState<T> = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'refreshing'; data: T } | { status: 'error'; message: string };The benefit isn’t just type safety.
The benefit is that every state is unambiguous.
Every state has a name. Every state only allows the data that belongs to it. Impossible combinations disappear.
const state: LoadingState<Task[]> = { status: 'success', data: [],};This unambiguously means:
The request succeeded. The result is an empty list.
Not:
Maybe not loaded yet.
Not:
Maybe an error with no error message.
Not:
Maybe an intermediate state.
A successful empty result is different from an initial state.
The model says so.
The template gets more boring
Section titled “The template gets more boring”With explicit state, the template doesn’t necessarily get shorter.
But it gets more honest.
@switch (state.status) { @case ('idle') {<app-empty-state />} @case ('loading') {<app-skeleton />} @case ('success') {<app-task-list [tasks]="state.data" />} @case ('refreshing') {<app-task-list [tasks]="state.data" /><app-small-spinner />} @case ('error') {<app-error-message [message]="state.message" />} }The template no longer queries three variables and tries to reconstruct meaning from them.
It renders a named state.
That’s the difference.
In a signal store
Section titled “In a signal store”In a signal store or a facade, this state can be held and derived cleanly.
type TasksFeatureState = { tasks: LoadingState<Task[]>;};
const initialState: TasksFeatureState = { tasks: { status: 'idle' },};On the first load:
patchState(store, { tasks: { status: 'loading' },});On success:
patchState(store, { tasks: { status: 'success', data: tasks },});On refresh:
const current = store.tasks();
if (current.status === 'success') { patchState(store, { tasks: { status: 'refreshing', data: current.data }, });}And on error:
patchState(store, { tasks: { status: 'error', message: 'Failed to load tasks.' },});Of course this is more modeling than a boolean.
But it’s less guessing.
And less guessing is usually good architecture.
When a boolean is enough
Section titled “When a boolean is enough”Not every loading state needs a union.
A boolean is fine when:
- the state is genuinely only local
- no existing data has to be preserved
- errors don’t play a separate role
- there’s no refresh to distinguish
- no parallel states arise
- the code stays small and clear
Example:
readonly saving = signal(false);For a single submit button, this can be completely sufficient.
But the moment you also need data, error, empty, loaded, refreshing, or initialized, that’s a signal.
Not in the Angular sense.
In the architectural sense.
At that point you’re probably modeling a state space with loose flags.
Conclusion
Section titled “Conclusion”isLoading isn’t the enemy.
But isLoading is often too small for the problem.
An asynchronous process doesn’t just consist of “loading” and “not loading.”
It can be initial. It can be running. It can succeed. It can fail. It can update existing data.
If these states are meaningfully different, they should be different in the model too.
The short rule:
Use booleans for real switches. Use state models for processes.
Or, shorter:
Loading isn’t a boolean. Loading is a state.