DEV Community

Cover image for Event Bus and State Managers: Friends or Rivals?
Art Stesh
Art Stesh

Posted on

Event Bus and State Managers: Friends or Rivals?

A couple of years ago I was reviewing a small merge request in the monitoring module of the agricultural platform I work on. The change was modest: when a field sensor reports a critical reading, the UI should show a toast, and the analytics counter should tick. The author wired it exactly the way the codebase had taught him — a new action type, a new reducer case that returned the previous state untouched, and two effects listening to that action to do the actual work.

The first comment under that merge request, from another reviewer, was a single line: "Why is this in the store at all?"

The thread grew to forty-something comments and produced no winner. Both sides were right: the store was where communication already lived in that module, and the change was genuinely a notification, not a piece of state. What struck me later was a number, not the argument. By that point the module's store carried around sixty action types, and seventeen of them had reducers that did nothing — literally return state. Nobody had done anything wrong; every individual decision was locally reasonable. But somewhere along the way the store had quietly become the module's notification system, and nobody had approved that decision, because it was never made as a decision.

State managers — NgRx, Redux, Zustand, Pinia; the family is large — and event buses are often presented as rivals competing for the same job. In my experience they are not rivals at all. A store answers the question "what is the current X?". A bus carries the statement "X just happened". The pain starts when one tool is asked to do the other's job — and it starts in both directions.

This article is about drawing that boundary honestly: what state and events actually are, what it costs to bend a store into a bus (and a bus into a store), how the two work together, and how to test the combination.


Two Different Questions

Let me pin down the definitions first, because most of the confusion lives here.

State is an answer to a question. "What is the latest reading of sensor 42?" "Which tab is selected?" An answer has to be available at any moment, to anyone who asks — including a component that mounted a second ago and never saw the events that produced the answer. The last write wins; earlier answers stop mattering; multiple readers must see a consistent value.

An event is a statement that something happened. "Sensor 42 reported 18.4 °C at 12:04." It is a fact about a moment, not an answer to a current question. Its value is in the reactions it triggers — a toast, a counter, a navigation — not in itself. Zero subscribers is a valid outcome: the sensor does not care whether anyone was listening.

The distinction is mechanical enough to have a direct expression in RxJS (and in Rx.NET, where I first met it years ago, still writing C#):

const somethingHappened = new Subject<number>();            // an event
const currentReading = new BehaviorSubject<number>(18.4);   // state
Enter fullscreen mode Exit fullscreen mode

A Subject tells new listeners only about the future. A BehaviorSubject hands every new listener the current answer immediately. That one difference — does a late subscriber receive anything? — is the whole argument of this article.

I have a law degree in addition to the engineering ones, and the metaphor I keep reaching for is this: state is a register, an event is a notification letter. Whoever queries the register gets the current record. The letter is delivered, acted upon, and filed away — and if two departments ever disagree, the register wins, not the inbox.

The most practical heuristic I know is the late-subscriber test: if a component mounts right now, must it receive the value without anyone re-sending anything? If yes, it is state — put it in a store. If the data only matters while it is fresh, and whoever was not there does not need it, it is an event.


When the Store Becomes a Bus

The misuse I have seen most often — the one from the merge request story — is a store quietly turning into a notification system. The symptoms are recognizable across codebases:

  • Actions with no-op reducers. The state does not change; the action exists purely to be heard. dispatch({type: '[Gateway] Reading Received'}) travels through reducers that return the same reference and lands in effects that do the work.
  • Effects as event handlers. Toasts, sounds, analytics ticks, navigation — one-time reactions, attached to the store's machinery as permanent infrastructure.
  • A "misc" or "events" slice. A junk drawer of action types nobody reduces, kept because every module was taught to communicate through the store.
  • Action flood in devtools. When you replay a minute of a busy screen, the timeline is dominated by notifications, and finding an actual state change becomes archaeology.

To be fair — and this matters — at small scale this works. A single team, a single module, a handful of features: the cost is ceremony, not architecture. I have shipped features this way and would do it again under the same constraints. The Redux style guide itself suggests modelling actions as events rather than setter commands, so the vocabulary is not even foreign. The cost appears when many modules need to talk. A store's action vocabulary is a public interface: every module that dispatches or listens couples itself to that central registry. With dozens of lazy modules, you get a contract that everyone shares and no one owns — and the lifecycle is wrong for the job, because effects are application-lifetime singletons, while many listeners (a modal, a dashboard widget) should live and die with their scope.

The rule of thumb I use in reviews: when roughly a third of a slice's actions have no-op reducers, the store is being used as a bus. Time to split the jobs.


When the Bus Becomes a Store

The opposite misuse is subtler, and I have been guilty of it too. It starts innocently: "let's keep the last value on the bus, so the component that mounts later gets it."

  • Replay hacks. A message keeps a BehaviorSubject internally, so late subscribers receive "the current value". Now there are two sources of truth for the same fact: the projected state in components and the hidden last value inside the bus.
  • Scattered projections. Each subscriber maintains its own derived slice of what it heard. Consistency is by convention; nothing guarantees two components computed from the same generation of data.
  • No timeline. No devtools history, no time travel, no undo. Debugging "how did the screen get into this state" means replaying logs by hand.
  • Startup races. Whoever mounts late must ask "give me the current value" — over an API designed for "tell me when something happens".

Again, fairness: for small, transient, single-writer coordination this is fine. A "theme changed" message with last-value semantics, one writer, a value that is trivially reconstructible — no harm done. The problem begins when the hidden last value becomes load-bearing: rendered in several places, validated, used as an input for further decisions. At that point it is state wearing an event's clothes.


What Backend Systems Already Know

Before I moved to frontend work, I spent years writing C# services. In that world, nobody proposes replacing the read database with the message broker. The broker carries facts — "order placed", "payment confirmed" — and projection handlers consume selected facts and update read models. Queries hit the read models. The broker is the flow; the database is the answer. (Yes, event sourcing exists, and it stores events as the source of truth — a real pattern, and a heavy one; but even there, queries are served from projections, not by replaying the whole log on demand.)

A frontend application is not different in kind. The bus is the flow; the store is the answer; and between them stands a projection — a small handler that subscribes to selected facts and updates the state. This is the shape I want to show in the example below.


Working Together: A Sensor Dashboard

Consider a stripped-down version of the dashboard we actually run: field sensors report through a gateway, and the screen must

  1. render a map with the latest reading per sensor — rendered, must be consistent across views, and a component mounted later needs the values immediately → state;
  2. show a toast and tick an analytics counter on critical readings — one-time facts with side effects and no renderable value → events;
  3. remember the selected sensor → state;
  4. manage the gateway connection itself — neither; it is a plain service, and neither tool should care.

The overall shape:

In the code below I use @artstesh/postboy as the bus implementation, because it gives me typed messages and subscription lifecycle management out of the box. The pattern is the point, not the library: a bus is a map from message type to subject, and you can write your own in an afternoon.

The event — a fact, nothing more:

export interface SensorReading {
  sensorId: string;
  value: number;
  severity: 'normal' | 'critical';
}

export class SensorReadingReceived extends PostboyGenericMessage {
  public static readonly ID = 'b1d9b4c2-2f3e-4a71-9c0d-6e5f7a8b9c01';

  constructor(public readonly reading: SensorReading) {
    super();
  }
}
Enter fullscreen mode Exit fullscreen mode

The publisher. Note what it does not know: toasts, analytics, the store. It reports a fact and returns to its business:

@Injectable({providedIn: 'root'})
export class GatewayConnection {
  constructor(private readonly bus: PostboyService) {}

  // invoked by the transport layer for every parsed frame
  private onReading(reading: SensorReading): void {
    this.bus.fire(new SensorReadingReceived(reading));
  }
}
Enter fullscreen mode Exit fullscreen mode

A pure event consumer — reacts and forgets, keeps no state:

export interface ToastPort {
  show(message: string): void;
}

@Injectable({providedIn: 'root'})
export class CriticalAlertService implements IPostboyDependingService {
  constructor(private readonly bus: PostboyService, private readonly toasts: ToastPort) {}

  up(): void {
    this.bus.sub(SensorReadingReceived).subscribe(({reading}) => {
      if (reading.severity === 'critical') {
        this.toasts.show(`Sensor ${reading.sensorId}: ${reading.value}`);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The projection — the bridge between the two worlds. It listens to selected facts and updates the answer:

@Injectable({providedIn: 'root'})
export class SensorProjection implements IPostboyDependingService {
  constructor(private readonly bus: PostboyService, private readonly store: SensorStore) {}

  up(): void {
    this.bus.sub(SensorReadingReceived).subscribe(({reading}) => {
      this.store.apply(new ReadingRecorded(reading));
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The store itself is deliberately boring — a reducer over a BehaviorSubject, no framework allegiance:

export interface SensorDashboardState {
  latestBySensor: Readonly<Record<string, SensorReading>>;
  selectedSensorId: string | null;
}

export class ReadingRecorded {
  constructor(public readonly reading: SensorReading) {}
}

export class SensorSelected {
  constructor(public readonly sensorId: string) {}
}

const EMPTY_STATE: SensorDashboardState = {latestBySensor: {}, selectedSensorId: null};

@Injectable({providedIn: 'root'})
export class SensorStore {
  private readonly state = new BehaviorSubject<SensorDashboardState>(EMPTY_STATE);
  public readonly view = this.state.asObservable();

  get snapshot(): SensorDashboardState {
    return this.state.value;
  }

  apply(action: ReadingRecorded | SensorSelected): void {
    this.state.next(reduce(this.state.value, action));
  }
}

function reduce(state: SensorDashboardState, action: ReadingRecorded | SensorSelected): SensorDashboardState {
  if (action instanceof ReadingRecorded) {
    return {
      ...state,
      latestBySensor: {...state.latestBySensor, [action.reading.sensorId]: action.reading},
    };
  }
  if (action instanceof SensorSelected) {
    return {...state, selectedSensorId: action.sensorId};
  }
  return state;
}
Enter fullscreen mode Exit fullscreen mode

And a component reads the answer — it never touches the event:

@Component({selector: 'sensor-map', template: '...'})
export class SensorMapComponent {
  public readonly readings$ = this.store.view.pipe(map(s => s.latestBySensor));

  constructor(private readonly store: SensorStore) {}
}
Enter fullscreen mode Exit fullscreen mode

One reading, two worlds — this is the whole flow in a single sequence:

A fair word for NgRx

If you already run NgRx, none of the above is impossible there: an action can carry the fact, an effect can toast, a reducer can project. I have done exactly this on NgRx projects and slept fine. The bus earns its place when:

  • modules should not share one action vocabulary — lazy modules, reusable library components, microfrontends where separate teams cannot share a store schema at all;
  • listeners must live and die with a scope (a widget, a modal, a route area), rather than exist as app-lifetime effects;
  • the same fact feeds several consumers that render nothing — analytics, logging, auditing — and you would rather not register them all in the store's machinery.

When Not to Use Each

Don't use a bus for:

  • consistent, rendered state that several views must agree on — that is the store's job, by construction;
  • values a late subscriber needs immediately — you will reinvent replay semantics;
  • undo, time travel, audit of state — stores have tooling for this, buses do not;
  • high-frequency input streams (raw mouse, scroll, keystrokes) — subscribe where the event happens.

Don't use a store for:

  • one-time notifications whose only life is in their reactions;
  • logging, analytics, auditing side effects — they are events by nature;
  • broadcasts across separately deployed parts — a shared store schema is the most expensive possible coupling;
  • decoupled contracts between modules that should not know about each other.

Use neither for: request-response data loading. Call a service, or — if you want the caller decoupled from the provider — use callback messages over the bus, a pattern I covered earlier in this series.

Question If yes
A component mounting right now needs the value with no re-send? State
Several views must render it consistently? State
Undo / time travel / audit of values? State
One-time fact, side effects only (toast, sound, analytics, navigation)? Event
Independently deployed parts need to talk? Event
Ask a question now, get an answer later? Neither — service call or callback message

Testing Both Sides

Automated testing is where I care about this split the most — it is usually where architecture decisions either pay off or present their bill. The split pays off. Every piece of the picture above is testable in isolation, with exactly one thing set up per test:

  • Reducers are pure functions — assert input against output, no mocks at all.
  • Publishers — assert that the fact was fired, and nothing else about the component.
  • Projections — given an event, the state must change in a specific way.
  • Consumers — given an event, the side effect happened.

Here is the projection test. PostboyWorld comes from @artstesh/postboy-testing — a recording mock of the bus. Two tiny helpers keep the snippets self-contained: aReading, a one-line test-data builder, and RecordingToasts, a fake port that collects messages. The // separators are my team's convention for marking arrange, act and assert:

const aReading = (over: Partial<SensorReading> = {}): SensorReading => ({
  sensorId: 'field-7-north',
  value: 18.4,
  severity: 'normal',
  ...over,
});

class RecordingToasts {
  public readonly shown: string[] = [];

  show(message: string): void {
    this.shown.push(message);
  }
}

describe('SensorProjection', () => {
  let world: PostboyWorld;
  let store: SensorStore;

  beforeEach(() => {
    world = new PostboyWorld();
    store = new SensorStore();
  });

  it('projects a received reading into the latest-by-sensor map', () => {
    const reading = aReading({sensorId: 'field-7-north', value: 18.4});
    //
    world.given.event(new SensorReadingReceived(reading));
    new SensorProjection(world.postboy, store).up();
    //
    expect(store.snapshot.latestBySensor['field-7-north']).toEqual(reading);
  });
});
Enter fullscreen mode Exit fullscreen mode

And the consumer test:

describe('CriticalAlertService', () => {
  let world: PostboyWorld;
  let toasts: RecordingToasts;

  beforeEach(() => {
    world = new PostboyWorld();
    toasts = new RecordingToasts();
    new CriticalAlertService(world.postboy, toasts).up();
  });

  it('shows a toast only for critical readings', () => {
    //
    world.postboy.fire(new SensorReadingReceived(aReading({severity: 'critical'})));
    world.postboy.fire(new SensorReadingReceived(aReading({severity: 'normal'})));
    //
    expect(toasts.shown.length).toBe(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

A note on the two ways these tests deliver events. world.given.event(...) re-registers the message type with a replay subject, so it is the right tool when the fact must already be on the bus before the listener starts — the projection subscribes only when it comes up, so it receives the replayed reading. When the listener is already live and the fact simply arrives, fire it on the mock bus directly, as the consumer test does.

Note what is absent from both tests: no component harness, no DOM, no mocked HttpClient chain, no store scaffolding just to deliver one event. When a junior developer on my team can write the second test in three minutes without reading documentation, I consider the architecture decision validated — whatever tool it was made for.


Conclusion

So, friends or rivals? Friends with different jobs, and the rivalry only exists when we hand one of them the other's contract:

  • State is an answer; an event is a fact. The register wins over the inbox when they disagree.
  • Run the late-subscriber test: whoever mounts now and needs the value without a re-send is asking for state.
  • Render → usually state. React without rendering → usually an event.
  • Let flows travel through the bus, answers live in the store, and keep a small honest projection between them.

None of this requires a particular library. The examples above use @artstesh/postboy because it is the implementation I know best — typed messages, callback queries, executors, and lifecycle management — but the pattern works with any typed bus, or with one you write yourself. If you want to see the pattern in a larger context, the event-driven frontend series covers the message types this dashboard relies on.

If your store has started accumulating actions nobody reduces, or your bus has started remembering "the last value", neither tool is broken — they are just doing each other's work. Draw the line, and both of them get easier to reason about.

Thank you for reading — feedback and counterarguments are always welcome, especially the counterarguments.

Top comments (0)