Skip to content

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.

State isn't something a component owns. State is the result of a flow.

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.

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.

UI state needs meaning, not just fields.

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().

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.

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.

Events explain why state comes into being.

That sounds like more structure.

It is.

But it’s structure exactly where chaos would otherwise appear.

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.

When the component decides everything, it's no longer just UI.

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.

Frontend architecture begins where data turns into behavior.

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.

A template should present meaning, not guess at it.

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.

Multiple reactions aren't a nested sequence. They're recipients of the same event.

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.

A usable frontend structure often looks like this:

  1. Component Renders the ViewModel and sends user intents.

  2. Facade / Store Accepts intents, holds internal state, produces events, or reacts to results.

  3. Effects / Services Execute technical side effects: HTTP, router, toast, dialog, storage.

  4. Projection / Selector / Computed Translates internal state into a screen ViewModel.

  5. Template Displays meaning.

That’s not bureaucracy.

That’s division of labor.

Good boundaries make behavior visible.

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.

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.

When behavior has no home, it spreads out.

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, then saving becomes active.
  • When TaskCreateSucceeded, then saving ends.
  • 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.

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.

State isn't what you set. State is what your system derives from events.

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.