Event-driven Projection
State isn’t the truth.
At least not in the frontend.
State is a snapshot. A projection. A picture that emerges from events, loaded data, user intents, rules, and side effects.
And yet a lot of frontends treat their state like one big drawer:
There’s an object somewhere. Someone changes it. Eventually everyone’s confused.
That sounds pragmatic.
Until it isn’t anymore.

The myth: state is just an object
Section titled “The myth: state is just an object”The myth goes roughly like this:
“We just need one variable for the current state.”
Or a bit more modern:
“It’s just a signal.”
Or classic Angular:
“It’s just a BehaviorSubject.”
Or especially bold:
“We’ll just set that directly in the component.”
The problem isn’t the variable.
The problem is the idea that state becomes simple just because you can touch it directly.
Direct mutation feels fast at first. You write less structure. Less boilerplate. Less “architecture.” The component loads data, sets flags, changes lists, sorts entries, toggles buttons, computes visibility, and shows toasts on the side.
Works.
For three fields.
For one form.
For a demo.
Then real product development shows up.
Frontend state is rarely just state
Section titled “Frontend state is rarely just state”A UI state often looks harmless:
{ items: [], selectedId: null, loading: false, error: null}That looks tidy.
But in reality, there’s more behind it:
- Was it loaded?
- Is it currently loading?
- Is the result empty?
- Is it empty because nothing exists?
- Or empty because a filter is active?
- Did an error happen?
- Is it a domain error?
- Is the error technical?
- Is the user allowed to see this action?
- Can the user perform it?
- Is the action currently locked?
- Does it need to reload after success?
- Does navigation need to happen after success?
- Does a toast need to appear after success?
- Is the displayed state still current?
And suddenly loading: false isn’t so innocent anymore.
It’s a small boolean smoke machine.

Frontend state is rarely just a data structure.
It’s the answer to the question:
What does the current state of the system mean for this screen?
And that meaning shouldn’t be randomly scattered across the template, three subscriptions, two effects, and one tired tap().
The problem with direct mutation
Section titled “The problem with direct mutation”Direct mutation isn’t automatically wrong.
But it has one dangerous property:
It erases history.
When a component just says:
this.state.loading = true;nobody knows later why.
Was it an initial load?
A refresh?
A retry?
A save?
A navigation?
A silent background sync?
It gets even worse when several places change the same state:
this.loading.set(true);this.error.set(null);this.items.set(result);this.selectedId.set(id);this.showSuccessToast();this.router.navigate(...);That’s not a flow.
That’s an accident scene with good intentions.
The order matters. The side effects matter. The business meaning matters. But in the code, it all looks like a few harmless assignments.
And that’s exactly where UI architecture starts to cough.
Events make meaning visible
Section titled “Events make meaning visible”An event doesn’t just describe what changed.
It describes why something changed.
The difference is decisive.
Not:
loading = true;Instead:
TaskCreateRequested;Not:
items = [...]Instead:
TasksLoaded;Not:
error = error;Instead:
TaskCreateFailed;Not:
selectedId = id;Instead:
TaskSelected;State then emerges from these events.
Not from a component’s mood.

That sounds like more structure.
It is.
But it’s structure exactly where chaos would otherwise appear.
Projection, not a data box
Section titled “Projection, not a data box”A projection is derived state.
It doesn’t just answer:
What data do we have?
It answers:
What should the UI make of it?
A screen ViewModel, for example, can be built from multiple sources:
type TaskDetailViewModel = { title: string; description: string; statusLabel: string; canEdit: boolean; canDelete: boolean; isBusy: boolean; primaryAction: { label: string; disabled: boolean; }; message?: string;};This ViewModel isn’t simply the API response.
It’s also not simply the store state.
It’s the projection from:
- server data
- loading state
- user role
- current actions
- validation rules
- error states
- business constraints
- UI context
And that’s exactly why this logic doesn’t belong randomly scattered in the template.
Templates are supposed to render.
Not figure out whether the user currently isn’t allowed to click because of status, role, some additional condition, loading phase, or cosmic frontend radiation.
The classic mistake: components own the state
Section titled “The classic mistake: components own the state”A lot of frontends start with this architecture:
@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('Saved'); this.reload(); }, error: () => { this.loading.set(false); this.error.set('Save failed'); }, }); }}This isn’t “bad” because Signals are bad.
This is bad because the component knows too much.
It knows:
- API flows
- loading phases
- error handling
- toast logic
- reload strategy
- UI state
- business meaning
- technical sequencing
The component is no longer a view.
It’s a small process manager wearing a template costume.
And small process managers rarely stay small for long.

The better boundary: intent in, ViewModel out
Section titled “The better boundary: intent in, ViewModel out”A robust frontend boundary is surprisingly simple to describe:
The component sends intents in and gets meaning back out.
So:
@Component(...)export class TaskDetailComponent { readonly vm = this.taskDetailFacade.vm;
saveClicked() { this.taskDetailFacade.createRequested(this.form.value); }
retryClicked() { this.taskDetailFacade.reloadRequested(); }}The component doesn’t need to know whether an HTTP call happens afterward.
It doesn’t need to know whether a toast appears.
It doesn’t need to know whether a reload is needed.
It doesn’t need to know whether an event triggers multiple reactions.
It just says:
The user wants to save.
The rest is application flow.
And that flow belongs in a store, a facade, an effect, an event handler, or a comparable application layer.
Not in the click handler.
Event-driven doesn’t mean: everything is event sourcing
Section titled “Event-driven doesn’t mean: everything is event sourcing”Now for the legitimate objection:
“Do we now have to build event sourcing for every button?”
No.
Please, no.
This article isn’t a plea to turn every frontend into a distributed event-sourcing system with an auditable append-only log.
We’re talking about a mental model and structure.
Event-driven projection in the frontend means:
- User actions get modeled as intents.
- Relevant outcomes become visible as events.
- State gets derived from that.
- ViewModels translate data into UI meaning.
- Side effects hang off events, not off components at random.
- Components stay thin.
That can happen with NgRx.
With the NgRx Signal Store.
With RxJS.
With Signals.
With your own small facade.
With a simple reducer.
The technique is secondary.
The architecture question is:
Can I trace why this screen currently looks the way it does?
If the only answer is “because a signal got set somewhere,” things get difficult.
Why this matters especially in the frontend
Section titled “Why this matters especially in the frontend”Backend developers often underestimate how much process logic lives in modern frontends.
A frontend is no longer just a form plus a table.
It coordinates:
- asynchronous data flows
- local changes
- server state
- optimistic updates
- validation
- navigation
- permissions
- error states
- loading states
- filtering
- sorting
- pagination
- real-time updates
- cross-component communication
And then someone says:
“Just do that in the component.”
Sure.
And we’ll handle fire safety with scented candles.

The more a screen coordinates, the more dangerous direct state gets.
Not because direct mutation is technically impossible.
But because it makes the system logic invisible.
The difference between state and projection
Section titled “The difference between state and projection”State is often internal.
Projection is consumable.
An internal store state can look technical:
type TaskState = { entities: Record<string, Task>; selectedId: string | null; loadStatus: 'idle' | 'loading' | 'loaded' | 'failed'; saveStatus: 'idle' | 'saving' | 'failed'; error: unknown;};A ViewModel should be more domain-oriented and closer to the UI:
type TaskDetailVm = { headline: string; statusText: string; showSkeleton: boolean; showEmptyState: boolean; errorText?: string; canSave: boolean; saveButtonLabel: string;};The mistake happens when you dump internal state straight into the template.
Then you get template logic like this:
@if (!loading() && !error() && items().length === 0 && !filterActive()) {<app-empty-state />} @if (!loading() && !error() && items().length === 0 && filterActive()) {<app-no-filter-results />}That’s not rendering.
That’s decision logic with HTML syntax.
Better:
@if (vm().emptyState; as emptyState) {<app-empty-state [kind]="emptyState.kind" [message]="emptyState.message" />}The decision belongs in the projection.
The template gets meaning.
Not raw material.

Events decouple reactions
Section titled “Events decouple reactions”One especially valuable effect of events:
Multiple reactions can respond to the same event without getting nested inside each other.
Example:
After a successful save:
- the dialog should close
- the list should reload
- a toast should appear
- navigation might need to happen
- the local save status should reset
The direct approach often looks like this:
save() { this.api.save(payload).pipe( switchMap(() => this.reload()), tap(() => this.toast.success('Saved')), tap(() => this.dialog.close()), tap(() => this.router.navigate(['/tasks'])) ).subscribe();}That’s a pipeline.
From the domain perspective, these are several reactions to one event.
The event is:
TaskSaved;Multiple handlers can react to it:
on(TaskSaved, reloadTasks);on(TaskSaved, showSuccessToast);on(TaskSaved, closeDialog);on(TaskSaved, navigateToOverview);That’s not an end in itself.
It makes something visible:
The success is the event. The reactions are independent.
Otherwise a save pipeline quickly turns into reaction spaghetti.
And reaction spaghetti is hard to test, hard to change, and especially good at personally offending someone after six months.

Management perspective: why this isn’t just developer aesthetics
Section titled “Management perspective: why this isn’t just developer aesthetics”Of course you can say:
“That’s just internal structure.”
Yes.
The same way structural engineering in a building is “just internal structure” too.
You still see the effects, though.
Directly mutated UI state doesn’t immediately set the project on fire. It leads to something much more unpleasant:
change that keeps getting slower.
At the start, everything’s fast.
Then edge cases show up.
Then roles show up.
Then business exceptions show up.
Then new loading states show up.
Then a second screen shows up.
Then the same action is supposed to work the same way in three places.
Then nobody can figure out anymore why navigation happens after saving sometimes and not other times.
And suddenly a small change costs three days.
Not because developers are slow.
But because the system has no traceable structure for behavior.
That’s the point management often doesn’t see:
You don’t pay for architecture because developers like drawing pretty diagrams.
You invest in architecture so that behavior remains easy to change later.
Team perspective: shared language instead of click-handler archaeology
Section titled “Team perspective: shared language instead of click-handler archaeology”Events help teams talk about behavior.
Not:
“Loading gets set to false somewhere over there.”
But:
“After
TaskCreateSucceeded, we reload the list and show a notification.”
That’s a different kind of sentence.
It’s more concrete.
More testable.
More discussable.
And it maps more naturally to domain workflows.
A tech lead can ask:
- Which events exist in this flow?
- Which projection results from them?
- Which reactions hang off which event?
- Which component is allowed to trigger which intent?
- Which states are internal?
- Which meaning goes into the ViewModel?
Those are architecture questions.
Not framework religion questions.
Whether Signals, RxJS, NgRx, or a hand-written store sits underneath matters.
But it’s secondary.
If the team doesn’t share a common vocabulary for events, projections, and UI meaning, even the most modern state tool only helps so much.
Then you just have chaos with a nice API.
Good boundaries in the frontend
Section titled “Good boundaries in the frontend”A usable frontend structure often looks like this:
-
Component Renders the ViewModel and sends user intents.
-
Facade / Store Accepts intents, holds internal state, produces events, or reacts to results.
-
Effects / Services Execute technical side effects: HTTP, router, toast, dialog, storage.
-
Projection / Selector / Computed Translates internal state into a screen ViewModel.
-
Template Displays meaning.
That’s not bureaucracy.
That’s division of labor.

Of course, not every small dialog needs five files and an architecture board.
But every relevant flow needs one place where behavior can be traced.
If that place is “a little bit everywhere,” that’s not architecture.
That’s hope with syntax highlighting.
When direct local mutation is completely fine
Section titled “When direct local mutation is completely fine”Not every piece of state deserves an event flow.
An expanded accordion section?
A local hover state?
A temporary tab in a purely local component?
A search field that only becomes relevant on submit?
Nobody needs an event choreography for that.
Local UI state is allowed to stay local.
The line isn’t:
“Am I allowed to set a signal?”
The better question is:
“Does this change have business meaning, or does it produce relevant side effects?”
If yes, it shouldn’t get mutated somewhere hidden.
If no, it stays local.
Architecture doesn’t mean making everything big.
Architecture means making the right things big enough.
Typical warning signs
Section titled “Typical warning signs”You often recognize a missing projection structure from sentences like:
“We only need this flag briefly.”
Of course.
Flags are always only brief.
Until they get read by three components, set by two effects, and married off in a template with !loading && !saving && !deleting.
More warning signs:
- “After saving, we’ll just quickly reload here too.”
- “The template checks that directly.”
- “The button is disabled if these five things aren’t true.”
- “That’s just how it comes from the API.”
- “We’ll set that in the service so all components can see it.”
- “That’s not real state, just a helper flag.”
- “I honestly don’t know right now why the toast shows up twice either.”
These aren’t small things.
They’re signs that behavior has no clear home.

Testability: events are better test points than incidental state changes
Section titled “Testability: events are better test points than incidental state changes”Directly mutated state is often hard to test, because you have to check flows through implementation details.
You click.
Then you wait.
Then you mock HTTP.
Then you go looking for some flag.
Then you hope the toast isn’t in the way.
With events and projections, tests get clearer:
- When
TaskCreateRequested, thensavingbecomes active. - When
TaskCreateSucceeded, thensavingends. - When
TaskCreateFailed, then an error message appears. - When there are no items and no filter is active, the projection shows an empty state.
- When there are no items and a filter is active, the projection shows “no results.”
These aren’t technical details.
These are behavior rules.
And tests like these survive refactors better than tests that check whether internally method X got called before method Y.
Good architecture doesn’t automatically make tests good.
But it gives tests better points of attack.
The real point: control over meaning
Section titled “The real point: control over meaning”Event-driven projection isn’t dogma in the frontend.
It’s an answer to a real problem:
UI behavior comes from many sources.
When these sources directly mutate components and objects, meaning becomes arbitrary.
When intents, events, and projections have clean boundaries, meaning becomes visible.
That’s the difference between:
“State got changed somewhere.”
and:
“This event produced this projection, which is why the screen looks the way it does.”
The second one is architecture.
The first one is debugging as a way of life.

The takeaway
Section titled “The takeaway”State isn’t something you set somewhere.
State is what your system derives from events.
Ignore that difference, and you’re not building a simple UI.
You’re building a black box with buttons.