Event-driven Projection
Zustand ist nicht die Wahrheit.
Zumindest nicht im Frontend.
Zustand ist eine Momentaufnahme. Eine Projektion. Ein Bild, das aus Ereignissen, geladenen Daten, Benutzerabsichten, Regeln und Nebenwirkungen entsteht.
Und trotzdem behandeln viele Frontends ihren Zustand wie eine große Schublade:
Irgendwo liegt ein Objekt.
Irgendjemand ändert es.
Irgendwann wundern sich alle.
Das klingt pragmatisch.
Bis es das nicht mehr ist.

Der Mythos: Zustand ist einfach ein Objekt
Abschnitt betitelt „Der Mythos: Zustand ist einfach ein Objekt“Der Mythos geht ungefähr so:
„Wir brauchen doch nur eine Variable für den aktuellen Zustand.“
Oder etwas moderner:
„Das ist doch nur ein Signal.“
Oder klassisch Angular:
„Das ist doch nur ein BehaviorSubject.“
Oder besonders mutig:
„Das setzen wir direkt in der Komponente.“
Das Problem ist nicht die Variable.
Das Problem ist die Vorstellung, dass Zustand dadurch einfach wird, dass man ihn direkt anfassen kann.
Direkte Mutation fühlt sich am Anfang schnell an. Man schreibt weniger Struktur. Weniger Boilerplate. Weniger „Architektur“. Die Komponente lädt Daten, setzt Flags, verändert Listen, sortiert Einträge, toggelt Buttons, berechnet Sichtbarkeit und zeigt nebenbei noch Toasts an.
Funktioniert.
Für drei Felder.
Für eine Maske.
Für eine Demo.
Dann kommt echte Produktentwicklung.
Frontend-Zustand ist selten nur Zustand
Abschnitt betitelt „Frontend-Zustand ist selten nur Zustand“Ein UI-Zustand sieht oft harmlos aus:
{ items: [], selectedId: null, loading: false, error: null}Das wirkt übersichtlich.
Aber in der Realität steckt dahinter mehr:
- Wurde geladen?
- Wird gerade geladen?
- Ist das Ergebnis leer?
- Ist es leer, weil nichts existiert?
- Oder leer, weil ein Filter aktiv ist?
- Ist ein Fehler passiert?
- Ist der Fehler fachlich?
- Ist der Fehler technisch?
- Darf der Benutzer diese Aktion sehen?
- Darf er sie ausführen?
- Ist die Aktion gerade gesperrt?
- Muss nach Erfolg neu geladen werden?
- Muss nach Erfolg navigiert werden?
- Muss nach Erfolg ein Toast erscheinen?
- Ist der angezeigte Zustand noch aktuell?
Und plötzlich ist loading: false nicht mehr so unschuldig.
Es ist ein kleiner boolescher Nebelwerfer.

Frontend-Zustand ist selten nur eine Datenstruktur.
Er ist die Antwort auf die Frage:
Was bedeutet der aktuelle Stand des Systems für diesen Screen?
Und diese Bedeutung sollte nicht zufällig im Template, in drei Subscriptions, zwei Effects und einem müden tap() verteilt sein.
Das Problem direkter Mutation
Abschnitt betitelt „Das Problem direkter Mutation“Direkte Mutation ist nicht automatisch falsch.
Aber sie hat eine gefährliche Eigenschaft:
Sie löscht Geschichte.
Wenn eine Komponente einfach sagt:
this.state.loading = true;weiß später niemand mehr, warum.
War es ein initialer Load?
Ein Refresh?
Ein Retry?
Ein Speichern?
Eine Navigation?
Ein stiller Hintergrundabgleich?
Noch schlimmer wird es, wenn mehrere Stellen denselben Zustand verändern:
this.loading.set(true);this.error.set(null);this.items.set(result);this.selectedId.set(id);this.showSuccessToast();this.router.navigate(...);Das ist kein Flow.
Das ist eine Unfallstelle mit guter Absicht.
Die Reihenfolge ist wichtig. Die Seiteneffekte sind wichtig. Die fachliche Bedeutung ist wichtig. Aber im Code sieht alles aus wie ein paar harmlose Zuweisungen.
Und genau dort beginnt UI-Architektur zu husten.
Ereignisse machen Bedeutung sichtbar
Abschnitt betitelt „Ereignisse machen Bedeutung sichtbar“Ein Event beschreibt nicht nur, was sich geändert hat.
Es beschreibt, warum sich etwas geändert hat.
Der Unterschied ist entscheidend.
Nicht:
loading = true;Sondern:
TaskCreateRequested;Nicht:
items = [...]Sondern:
TasksLoaded;Nicht:
error = error;Sondern:
TaskCreateFailed;Nicht:
selectedId = id;Sondern:
TaskSelected;Der Zustand entsteht dann aus diesen Ereignissen.
Nicht aus der Laune einer Komponente.

Das klingt nach mehr Struktur.
Ist es auch.
Aber es ist Struktur an der Stelle, an der sonst Chaos entsteht.
Projektion statt Datenkiste
Abschnitt betitelt „Projektion statt Datenkiste“Eine Projection ist ein abgeleiteter Zustand.
Sie beantwortet nicht nur:
Welche Daten haben wir?
Sondern:
Was soll die UI daraus machen?
Ein Screen-ViewModel kann zum Beispiel aus mehreren Quellen entstehen:
type TaskDetailViewModel = { title: string; description: string; statusLabel: string; canEdit: boolean; canDelete: boolean; isBusy: boolean; primaryAction: { label: string; disabled: boolean; }; message?: string;};Dieses ViewModel ist nicht einfach die API-Response.
Es ist auch nicht einfach der Store-State.
Es ist die Projektion aus:
- Serverdaten
- Ladezustand
- Benutzerrolle
- aktuellen Aktionen
- Validierungsregeln
- Fehlerzuständen
- fachlichen Einschränkungen
- UI-Kontext
Und genau deshalb gehört diese Logik nicht zufällig ins Template.
Templates sollen rendern.
Nicht ermitteln, ob der Benutzer wegen Status, Rolle, Nebenbedingung, Ladephase oder kosmischer Frontend-Strahlung gerade nicht klicken darf.
Der klassische Fehler: Komponenten besitzen den Zustand
Abschnitt betitelt „Der klassische Fehler: Komponenten besitzen den Zustand“Viele Frontends starten mit dieser Architektur:
@Component(...)export class TaskDetailComponent { task = signal<Task | null>(null); loading = signal(false); error = signal<string | null>(null);
save() { this.loading.set(true);
this.api.save(...).subscribe({ next: () => { this.loading.set(false); this.toast.success('Gespeichert'); this.reload(); }, error: () => { this.loading.set(false); this.error.set('Speichern fehlgeschlagen'); }, }); }}Das ist nicht „schlecht“, weil Signals schlecht wären.
Das ist schlecht, weil die Komponente zu viel weiß.
Sie kennt:
- API-Abläufe
- Ladephasen
- Fehlerbehandlung
- Toast-Logik
- Reload-Strategie
- UI-Zustand
- fachliche Bedeutung
- technische Reihenfolge
Die Komponente ist nicht mehr View.
Sie ist ein kleiner Prozessmanager im Template-Kostüm.
Und kleine Prozessmanager werden selten lange klein bleiben.

Die bessere Grenze: Intent rein, ViewModel raus
Abschnitt betitelt „Die bessere Grenze: Intent rein, ViewModel raus“Eine robuste Frontend-Grenze ist erstaunlich einfach zu beschreiben:
Die Komponente sendet Absichten hinein und bekommt Bedeutung heraus.
Also:
@Component(...)export class TaskDetailComponent { readonly vm = this.taskDetailFacade.vm;
saveClicked() { this.taskDetailFacade.createRequested(this.form.value); }
retryClicked() { this.taskDetailFacade.reloadRequested(); }}Die Komponente muss nicht wissen, ob danach ein HTTP-Call passiert.
Sie muss nicht wissen, ob ein Toast erscheint.
Sie muss nicht wissen, ob ein Reload nötig ist.
Sie muss nicht wissen, ob ein Event mehrere Reaktionen auslöst.
Sie sagt nur:
Der Benutzer möchte speichern.
Der Rest ist Anwendungsfluss.
Und genau dieser Fluss gehört in Store, Facade, Effect, Event-Handler oder eine vergleichbare Anwendungsschicht.
Nicht in den Click-Handler.
Event-driven heißt nicht: alles ist Event Sourcing
Abschnitt betitelt „Event-driven heißt nicht: alles ist Event Sourcing“Jetzt kommt der berechtigte Einwand:
„Müssen wir jetzt für jeden Button Event Sourcing bauen?“
Nein.
Bitte nicht.
Dieser Artikel ist kein Plädoyer dafür, jedes Frontend in ein verteiltes Event-Sourcing-System mit auditierbarem Append-Only-Log zu verwandeln.
Wir reden über Denkmodell und Struktur.
Event-driven Projection im Frontend heißt:
- Benutzeraktionen werden als Absichten modelliert.
- Relevante Ergebnisse werden als Ereignisse sichtbar.
- Zustand wird daraus abgeleitet.
- ViewModels übersetzen Daten in UI-Bedeutung.
- Seiteneffekte hängen an Ereignissen, nicht zufällig an Komponenten.
- Komponenten bleiben dünn.
Das kann mit NgRx passieren.
Mit NgRx Signal Store.
Mit RxJS.
Mit Signals.
Mit einer eigenen kleinen Facade.
Mit einem simplen Reducer.
Die Technik ist zweitrangig.
Die Architekturfrage lautet:
Kann ich nachvollziehen, warum dieser Screen gerade so aussieht?
Wenn die Antwort nur lautet „weil irgendwo ein Signal gesetzt wurde“, wird es schwierig.
Warum das im Frontend besonders wichtig ist
Abschnitt betitelt „Warum das im Frontend besonders wichtig ist“Backend-Entwickler unterschätzen oft, wie viel Prozesslogik in modernen Frontends steckt.
Ein Frontend ist nicht mehr nur Formular plus Tabelle.
Es koordiniert:
- asynchrone Datenflüsse
- lokale Änderungen
- Serverzustand
- Optimistic Updates
- Validierung
- Navigation
- Berechtigungen
- Fehlerzustände
- Ladezustände
- Filter
- Sortierung
- Pagination
- Realtime-Updates
- Cross-Component-Kommunikation
Und dann sagt jemand:
„Mach das doch einfach in der Komponente.“
Klar.
Und Brandschutz machen wir mit Duftkerzen.

Je mehr ein Screen koordiniert, desto gefährlicher wird direkter Zustand.
Nicht weil direkte Mutation technisch unmöglich wäre.
Sondern weil sie die Systemlogik unsichtbar macht.
Der Unterschied zwischen State und Projection
Abschnitt betitelt „Der Unterschied zwischen State und Projection“State ist oft intern.
Projection ist konsumierbar.
Ein interner Store-State kann technisch aussehen:
type TaskState = { entities: Record<string, Task>; selectedId: string | null; loadStatus: 'idle' | 'loading' | 'loaded' | 'failed'; saveStatus: 'idle' | 'saving' | 'failed'; error: unknown;};Ein ViewModel sollte fachlicher und UI-näher sein:
type TaskDetailVm = { headline: string; statusText: string; showSkeleton: boolean; showEmptyState: boolean; errorText?: string; canSave: boolean; saveButtonLabel: string;};Der Fehler passiert, wenn man internen State direkt ins Template kippt.
Dann entsteht Template-Logik wie diese:
@if (!loading() && !error() && items().length === 0 && !filterActive()) {<app-empty-state />} @if (!loading() && !error() && items().length === 0 && filterActive()) {<app-no-filter-results />}Das ist nicht Rendering.
Das ist Entscheidungslogik mit HTML-Syntax.
Besser:
@if (vm().emptyState; as emptyState) {<app-empty-state [kind]="emptyState.kind" [message]="emptyState.message" />}Die Entscheidung gehört in die Projection.
Das Template bekommt Bedeutung.
Nicht Rohmaterial.

Events entkoppeln Reaktionen
Abschnitt betitelt „Events entkoppeln Reaktionen“Ein besonders wertvoller Effekt von Events:
Mehrere Reaktionen können auf dasselbe Ereignis reagieren, ohne dass sie ineinander verschachtelt werden.
Beispiel:
Nach erfolgreichem Speichern soll:
- der Dialog schließen
- die Liste neu laden
- ein Toast erscheinen
- eventuell navigiert werden
- der lokale Save-Status zurückgesetzt werden
Der direkte Ansatz sieht oft so aus:
save() { this.api.save(payload).pipe( switchMap(() => this.reload()), tap(() => this.toast.success('Gespeichert')), tap(() => this.dialog.close()), tap(() => this.router.navigate(['/tasks'])) ).subscribe();}Das ist eine Pipeline.
Aber fachlich sind es mehrere Reaktionen auf ein Ereignis.
Das Ereignis lautet:
TaskSaved;Darauf können mehrere Handler reagieren:
on(TaskSaved, reloadTasks);on(TaskSaved, showSuccessToast);on(TaskSaved, closeDialog);on(TaskSaved, navigateToOverview);Das ist kein Selbstzweck.
Es macht sichtbar:
Der Erfolg ist das Ereignis. Die Reaktionen sind unabhängig.
Sonst wird aus einer Save-Pipeline schnell ein Reaktionswurm.
Und Reaktionswürmer sind schwer zu testen, schwer zu ändern und besonders gut darin, nach sechs Monaten jemanden persönlich zu beleidigen.

Management-Perspektive: Warum das nicht nur Entwicklerästhetik ist
Abschnitt betitelt „Management-Perspektive: Warum das nicht nur Entwicklerästhetik ist“Natürlich kann man sagen:
„Das ist doch nur interne Struktur.“
Ja.
So wie Statik bei Gebäuden auch nur interne Struktur ist.
Die Auswirkungen sieht man trotzdem.
Direkt mutierter UI-Zustand führt nicht sofort zum Projektbrand. Er führt zu etwas viel Unangenehmerem:
langsamer werdender Änderung.
Am Anfang ist alles schnell.
Dann kommen Sonderfälle.
Dann kommen Rollen.
Dann kommen fachliche Ausnahmen.
Dann kommen neue Ladezustände.
Dann kommt ein zweiter Screen.
Dann soll dieselbe Aktion an drei Stellen gleich funktionieren.
Dann findet niemand mehr heraus, warum nach dem Speichern manchmal navigiert wird und manchmal nicht.
Und plötzlich kostet eine kleine Änderung drei Tage.
Nicht weil Entwickler langsam sind.
Sondern weil das System keine nachvollziehbare Struktur für Verhalten hat.
Das ist der Punkt, den Management oft nicht sieht:
Architektur bezahlt man nicht, weil Entwickler gerne hübsche Diagramme malen.
Architektur bezahlt man, damit Verhalten später noch änderbar bleibt.
Team-Perspektive: Gemeinsame Sprache statt Click-Handler-Archäologie
Abschnitt betitelt „Team-Perspektive: Gemeinsame Sprache statt Click-Handler-Archäologie“Events helfen Teams, über Verhalten zu sprechen.
Nicht:
„Da wird irgendwo loading auf false gesetzt.“
Sondern:
„Nach
TaskCreateSucceededreloaden wir die Liste und zeigen eine Benachrichtigung.“
Das ist ein anderer Satz.
Er ist konkreter.
Testbarer.
Diskutierbarer.
Und er passt besser zu fachlichen Abläufen.
Ein Tech Lead kann fragen:
- Welche Events gibt es in diesem Flow?
- Welche Projection entsteht daraus?
- Welche Reaktionen hängen an welchem Event?
- Welche Komponente darf welchen Intent auslösen?
- Welche Zustände sind intern?
- Welche Bedeutung geht ins ViewModel?
Das sind Architekturfragen.
Nicht Framework-Religionsfragen.
Ob darunter Signals, RxJS, NgRx oder ein handgeschriebener Store liegt, ist wichtig.
Aber zweitrangig.
Wenn das Team keinen gemeinsamen Begriff für Ereignisse, Projektionen und UI-Bedeutung hat, hilft auch das modernste State-Tool nur begrenzt.
Dann hat man eben Chaos mit schöner API.
Der gute Schnitt im Frontend
Abschnitt betitelt „Der gute Schnitt im Frontend“Eine brauchbare Frontend-Struktur sieht häufig so aus:
-
Komponente Rendert ViewModel und sendet Benutzerintents.
-
Facade / Store Nimmt Intents entgegen, hält internen Zustand, erzeugt Events oder reagiert auf Ergebnisse.
-
Effects / Services Führen technische Nebenwirkungen aus: HTTP, Router, Toast, Dialog, Storage.
-
Projection / Selector / Computed Übersetzt internen Zustand in ein Screen-ViewModel.
-
Template Zeigt Bedeutung an.
Das ist keine Bürokratie.
Das ist Arbeitsteilung.

Natürlich braucht nicht jeder kleine Dialog fünf Dateien und ein Architekturboard.
Aber jeder relevante Flow braucht einen Ort, an dem Verhalten nachvollziehbar ist.
Wenn dieser Ort „überall ein bisschen“ ist, ist das keine Architektur.
Das ist Hoffnung mit Syntaxhighlighting.
Wann direkte lokale Mutation völlig okay ist
Abschnitt betitelt „Wann direkte lokale Mutation völlig okay ist“Nicht jeder Zustand verdient einen Event-Flow.
Ein aufgeklappter Accordion-Bereich?
Ein lokaler Hover-Zustand?
Ein temporärer Tab in einer rein lokalen Komponente?
Ein Suchfeld, das erst beim Absenden relevant wird?
Dafür braucht niemand eine Event-Choreografie.
Lokaler UI-Zustand darf lokal bleiben.
Die Grenze ist nicht:
„Darf ich ein Signal setzen?“
Die bessere Frage ist:
„Hat diese Änderung fachliche Bedeutung oder erzeugt sie relevante Nebenwirkungen?“
Wenn ja, sollte sie nicht irgendwo versteckt mutiert werden.
Wenn nein, bleibt sie lokal.
Architektur bedeutet nicht, alles groß zu machen.
Architektur bedeutet, die richtigen Dinge groß genug zu machen.
Typische Warnsignale
Abschnitt betitelt „Typische Warnsignale“Man erkennt fehlende Projection-Struktur oft an Sätzen wie:
„Das Flag brauchen wir nur kurz.“
Natürlich.
Flags sind immer nur kurz.
Bis sie von drei Komponenten gelesen, von zwei Effects gesetzt und in einem Template mit !loading && !saving && !deleting verheiratet werden.
Weitere Warnsignale:
- „Nach dem Speichern machen wir hier noch schnell ein Reload.“
- „Das Template prüft das direkt.“
- „Der Button ist disabled, wenn diese fünf Sachen nicht zutreffen.“
- „Das kommt halt aus der API so.“
- „Wir setzen das im Service, damit alle Komponenten es sehen.“
- „Das ist kein richtiger Zustand, nur ein Hilfsflag.“
- „Warum der Toast zweimal kommt, weiß ich gerade auch nicht.“
Das sind keine Kleinigkeiten.
Das sind Hinweise darauf, dass Verhalten keinen klaren Ort hat.

Testbarkeit: Events sind bessere Testpunkte als Zufall
Abschnitt betitelt „Testbarkeit: Events sind bessere Testpunkte als Zufall“Direkt mutierter Zustand ist oft schwer zu testen, weil man Abläufe über Implementierungsdetails prüfen muss.
Man klickt.
Dann wartet man.
Dann mockt man HTTP.
Dann sucht man irgendein Flag.
Dann hofft man, dass der Toast nicht im Weg ist.
Mit Events und Projections werden Tests klarer:
- Wenn
TaskCreateRequested, dann wirdsavingaktiv. - Wenn
TaskCreateSucceeded, dann istsavingbeendet. - Wenn
TaskCreateFailed, dann entsteht eine Fehlermeldung. - Wenn keine Items und kein Filter aktiv ist, zeigt die Projection einen Empty State.
- Wenn keine Items und ein Filter aktiv ist, zeigt die Projection „keine Treffer“.
Das sind keine technischen Details.
Das sind Verhaltensregeln.
Und genau solche Tests überleben Refactorings besser als Tests, die prüfen, ob intern Methode X vor Methode Y aufgerufen wurde.
Gute Architektur macht Tests nicht automatisch gut.
Aber sie gibt Tests bessere Angriffspunkte.
Der eigentliche Punkt: Kontrolle über Bedeutung
Abschnitt betitelt „Der eigentliche Punkt: Kontrolle über Bedeutung“Event-driven Projection ist im Frontend kein Dogma.
Es ist eine Antwort auf ein reales Problem:
UI-Verhalten entsteht aus vielen Quellen.
Wenn diese Quellen direkt Komponenten und Objekte mutieren, wird Bedeutung zufällig.
Wenn Intents, Events und Projections sauber geschnitten sind, wird Bedeutung sichtbar.
Das ist der Unterschied zwischen:
„Irgendwo wurde Zustand geändert.“
und:
„Dieses Ereignis hat diese Projection erzeugt, deshalb sieht der Screen so aus.“
Das zweite ist Architektur.
Das erste ist Debugging als Lebensmodell.

Merksatz
Abschnitt betitelt „Merksatz“Zustand ist nicht das, was du irgendwo setzt.
Zustand ist das, was dein System aus Ereignissen ableitet.
Wenn du diesen Unterschied ignorierst, baust du keine einfache UI.
Du baust eine Blackbox mit Buttons.