Loading ist kein Boolean
isLoading: boolean ist nicht falsch.
Es ist nur oft zu klein.
Für einfache Fälle reicht ein Boolean völlig aus. Ein Button wird geklickt, eine Anfrage läuft, danach ist sie fertig. Kein vorhandener Zustand, kein Fehlerzustand, kein Refresh, keine parallelen Requests.
Dann ist isLoading pragmatisch.
Aber viele Frontend-Flows sind nicht so einfach.
Und dann beginnt der Schmerz.
Denn ein Boolean kann nur zwei Dinge ausdrücken:
isLoading = true;isLoading = false;Ein echter asynchroner Zustand hat aber mehr als zwei Bedeutungen.
Das Problem
Abschnitt betitelt „Das Problem“Ein Ladeprozess ist kein Schalter.
Er ist ein Zustandsraum.
Typische Zustände sind:
idle— es wurde noch nichts angefragtloading— die erste Anfrage läuftsuccess— Daten wurden erfolgreich geladenerror— die Anfrage ist fehlgeschlagenrefreshing— Daten sind vorhanden, aber werden im Hintergrund aktualisiert
Mit einem Boolean kollabiert das alles zu:
isLoading: boolean;Der Rest verschwindet nicht.
Er verteilt sich nur auf weitere Variablen.
isLoading = false;data: Task[] | null = null;error: string | null = null;Und jetzt beginnt das eigentliche Problem.
Was bedeutet dieser Zustand?
isLoading = false;data = null;error = null;Noch gar nicht geladen? Erfolgreich geladen, aber keine Daten? Fehler zurückgesetzt, aber Daten vergessen? Initialer Zustand nach Navigation? Zwischenzustand während eines Reloads?
Niemand weiß es genau.
Der Code muss es erraten.
Und wenn Code anfängt, Zustand zu erraten, ist die Architektur undicht.

Der versteckte Zustand
Abschnitt betitelt „Der versteckte Zustand“Das Problem an isLoading ist selten der Boolean selbst.
Das Problem ist der Zustand, der nicht modelliert wurde.
isLoading: boolean;data: Task[] | null;error: string | null;Diese drei Felder bilden gemeinsam einen Zustand.
Aber sie tun so, als wären sie unabhängig.
Dadurch entstehen Kombinationen, die fachlich keinen Sinn ergeben.
isLoading = true;data = null;error = 'Fehler beim Laden';Lädt es gerade? Oder ist ein Fehler passiert? Oder beides?
Noch ein Beispiel:
isLoading = false;data = [];error = 'Fehler beim Laden';Ist die leere Liste das Ergebnis? Oder sind alte Daten übrig geblieben? Soll die UI Fehler oder Liste anzeigen?
Und der Klassiker:
isLoading = true;data = existingTasks;error = null;Ist das ein initiales Loading? Oder ein Refresh mit vorhandenen Daten?
Für die UI ist das ein großer Unterschied.
Beim initialen Laden zeigt man vielleicht einen großen Skeleton State.
Beim Refresh zeigt man die vorhandenen Daten weiter und vielleicht nur einen kleinen Spinner.
Ein Boolean kann diesen Unterschied nicht ausdrücken.
Refreshing ist der Moment der Wahrheit
Abschnitt betitelt „Refreshing ist der Moment der Wahrheit“Der Unterschied zwischen loading und refreshing zeigt besonders gut, warum isLoading oft zu grob ist.
type TasksState = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: Task[] } | { status: 'refreshing'; data: Task[] } | { status: 'error'; message: string };Jetzt ist klar:
{ status: 'loading';}bedeutet:
Es gibt noch keine Daten. Die erste Anfrage läuft.
Und:
{ status: 'refreshing', data: tasks }bedeutet:
Es gibt bereits Daten. Eine neue Anfrage läuft im Hintergrund.
Das ist kein kosmetischer Unterschied.
Das beeinflusst die UI.
@if (state.status === 'loading') {<app-skeleton />} @if (state.status === 'refreshing') {<app-task-list [tasks]="state.data" /><app-small-spinner />}Ohne expliziten Zustand landet diese Unterscheidung oft im Template, in Hilfsvariablen oder in zufälligen Bedingungen.
@if (isLoading && !data) {<app-skeleton />} @if (isLoading && data) {<app-task-list [tasks]="data" /><app-small-spinner />}Das funktioniert.
Aber die Bedeutung steckt nicht im Modell.
Sie steckt in einer Bedingung.
Und Bedingungen wachsen.
Die Alternative: Discriminated Union
Abschnitt betitelt „Die Alternative: Discriminated Union“In TypeScript lässt sich ein Zustandsraum sehr sauber als Discriminated Union modellieren.
type LoadingState<T> = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'refreshing'; data: T } | { status: 'error'; message: string };Der Vorteil ist nicht nur Typ-Sicherheit.
Der Vorteil ist Eindeutigkeit.
Jeder Zustand hat einen Namen. Jeder Zustand erlaubt nur die Daten, die zu ihm gehören. Unmögliche Kombinationen verschwinden.
const state: LoadingState<Task[]> = { status: 'success', data: [],};Das bedeutet eindeutig:
Die Anfrage war erfolgreich. Das Ergebnis ist eine leere Liste.
Nicht:
Vielleicht noch nicht geladen.
Nicht:
Vielleicht Fehler ohne Fehlermeldung.
Nicht:
Vielleicht Zwischenzustand.
Ein leerer Erfolg ist etwas anderes als ein initialer Zustand.
Das Modell sagt es.
Im Template wird es langweiliger
Abschnitt betitelt „Im Template wird es langweiliger“Mit einem expliziten Zustand wird das Template nicht unbedingt kürzer.
Aber es wird ehrlicher.
@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" />} }Das Template fragt nicht mehr drei Variablen ab und versucht daraus eine Bedeutung zu rekonstruieren.
Es rendert einen benannten Zustand.
Das ist der Unterschied.
Im Signal Store
Abschnitt betitelt „Im Signal Store“In einem Signal Store oder einer Facade kann dieser Zustand sauber gehalten und abgeleitet werden.
type TasksFeatureState = { tasks: LoadingState<Task[]>;};
const initialState: TasksFeatureState = { tasks: { status: 'idle' },};Beim ersten Laden:
patchState(store, { tasks: { status: 'loading' },});Bei Erfolg:
patchState(store, { tasks: { status: 'success', data: tasks },});Beim Refresh:
const current = store.tasks();
if (current.status === 'success') { patchState(store, { tasks: { status: 'refreshing', data: current.data }, });}Und bei Fehler:
patchState(store, { tasks: { status: 'error', message: 'Aufgaben konnten nicht geladen werden.' },});Natürlich ist das mehr Modellierung als ein Boolean.
Aber es ist weniger Raten.
Und weniger Raten ist meistens gute Architektur.
Wann ein Boolean reicht
Abschnitt betitelt „Wann ein Boolean reicht“Nicht jeder Ladezustand braucht eine Union.
Ein Boolean ist okay, wenn:
- der Zustand wirklich nur lokal ist
- keine vorhandenen Daten erhalten bleiben müssen
- Fehler separat keine Rolle spielen
- kein Refresh unterschieden wird
- keine parallelen Zustände entstehen
- der Code klein und klar bleibt
Beispiel:
readonly saving = signal(false);Für einen einzelnen Submit-Button kann das völlig reichen.
Aber sobald du zusätzlich data, error, empty, loaded, refreshing oder initialized brauchst, ist das ein Signal.
Nicht im Angular-Sinn.
Sondern im architektonischen Sinn.
Dann modellierst du wahrscheinlich einen Zustandsraum mit losen Flags.
isLoading ist kein Feind.
Aber isLoading ist oft zu klein für das Problem.
Ein asynchroner Prozess besteht nicht nur aus „lädt“ und „lädt nicht“.
Er kann initial sein. Er kann laufen. Er kann erfolgreich sein. Er kann fehlschlagen. Er kann vorhandene Daten aktualisieren.
Wenn diese Zustände fachlich unterschiedlich sind, sollten sie auch im Modell unterschiedlich sein.
Die kurze Regel:
Nutze Booleans für echte Schalter. Nutze Zustandsmodelle für Prozesse.
Oder noch kürzer:
Loading ist kein Boolean. Loading ist ein Zustand.