DEV Community

Cover image for NgRx Signal Store Event API: A Modern Take on Event-Driven Architecture
Duško Perić
Duško Perić

Posted on

NgRx Signal Store Event API: A Modern Take on Event-Driven Architecture

If you've ever worked with NgRx Signal Store, you know it's a powerful tool for managing state in Angular applications in a totally new way. But what if you could take it a step further? Imagine an event-driven system where the flow of state is completely decoupled, giving you incredible flexibility with minimal boilerplate.

Well, that's exactly what the NgRx SignalStore Events feature brings to the table. Now, your code can operate on a principle of "something happened, so I'm telling everyone who's interested."

Imagine you're playing a game and your hero just took down a tough enemy. A bunch of stuff happens all at once, right? Your hero gets XP, maybe some gold drops, and a status message pops up on the screen. Traditionally, getting all these things to happen smoothly could be a tangled mess of interconnected functions and state updates. It's like trying to untangle a hundred fishing lines at once.

The new Event API is the clean solution to this problem. Instead of your hero component directly telling the XP store to update, the hero just shouts, "Enemy Slain!". Everyone else, the XP tracker, the gold counter, the UI message service listens for that shout and reacts. It's a much cleaner, more scalable way to build things.

How It Works

Let's dive into a real-world scenario from our game. We need to handle a single event: an enemy is defeated. This simple action triggers a chain reaction across our game's state.
First, let's define the state we'll be managing with our HeroStore. We'll track the hero's core stats.

// hero.store.ts
export interface HeroState {
  level: number;
  xp: number;
  gold: number;
}

export const initialState: HeroState = {
  level: 1,
  xp: 0,
  gold: 0,
};
Enter fullscreen mode Exit fullscreen mode

Next, we define the events. The most important part of this is that the events carry the necessary data. In our case, when an enemy is defeated, we need to know the XP and gold they were carrying. A separate event for a level-up is what keeps our logic clean.

// hero.events.ts
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';

export const heroPanelEvents= eventGroup({
  source: 'Hero Panel',
  events: {
    enemyDefeated: type<{ xpValue: number; goldDrop: number }>(),
  },
});

export const heroApiEvents = eventGroup({
  source: 'Hero API',
  events: {
    levelUp: type<{ newLevel: number }>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

Now for the HeroStore itself. Here, we combine two key pieces: the reducer and the effects.

The reducer's job is to update the state based on the event's payload.
The effects handle the "side effects", any complex logic, calculations, or API calls that don't directly change the state.

// hero.store.ts
import { signalStore, withState } from '@ngrx/signals';
import { withReducer, on } from '@ngrx/signals/events';
import { heroPanelEvents, heroApiEvents } from './hero.events';
import { withHeroEffects } from './withHeroEffects';

export interface HeroState {
  level: number;
  xp: number;
  gold: number;
}

export const initialState: HeroState = {
  level: 1,
  xp: 0,
  gold: 0,
};

export const HeroStore = signalStore(
  withState<HeroState>(initialState),
  withHeroEffects(),
  withReducer(
    on(heroPanelEvents.enemyDefeated, ({ payload }) => ({ gold, xp }) => ({
      xp: xp + payload.xpValue,
      gold: gold + payload.goldDrop,
    })),
    on(heroApiEvents.levelUp, ({ payload }) => ({
      level: payload.newLevel,
    }))
  )
);
Enter fullscreen mode Exit fullscreen mode
// withHeroEffects.ts
import { inject } from '@angular/core';
import { signalStoreFeature, type } from '@ngrx/signals';
import { withEffects } from '@ngrx/signals/events';
import { Events } from '@ngrx/signals/events';
import { map, switchMap } from 'rxjs';
import { heroPanelEvents, heroApiEvents } from './hero.events';
import { HeroState } from './hero.store';
import { HeroService } from './hero.service';

export function withHeroEffects() {
  return signalStoreFeature(
    type<{ state: HeroState }>(),
    withEffects(
      (
        store, 
        events = inject(Events), 
        heroService = inject(HeroService)
      ) => ({
      levelUpEffect: events.on(heroPanelEvents.enemyDefeated).pipe(
        switchMap(() =>
          heroService.checkLevel(store.xp()).pipe(
            map((newLevel: number) => {
              if (newLevel > store.level()) {
                return heroApiEvents.levelUp({ newLevel });
              }
              return null;
            })
          )
        )
      )
    }))
  );
}
Enter fullscreen mode Exit fullscreen mode

The withEffects feature is where our logic for leveling up lives.
Here, the effect listens for the enemyDefeated event. When that event occurs, it calls heroService.checkLevel with the hero's current XP. The backend returns the hero's new level, if any. If the returned newLevel is higher than the current level, the effect emits heroApiEvents.levelUp. The signalStore automatically dispatches this event, which is then handled by the reducer to update the state.

This is a beautiful example of how effects and reducers work together. The effect handles the "what-if" logic, and the reducer handles the final state update.

Putting It All Together

So you've set up your store, defined your events, and even created an effect. Now, what do you do in your component? This is where everything clicks.

Thanks to the injectDispatch and signalStore APIs, your component code becomes incredibly clean. You just tell the app what happened, and the rest is handled by the system.

// hero.component.ts
import { Component, inject } from '@angular/core';
import { HeroStore } from './hero.store';
import { heroPanelEvents } from './hero.events';
import { injectDispatch } from '@ngrx/signals/events';

@Component({
  selector: 'app-hero-panel',
  template: `
    <h2>Hero Stats</h2>
    <p>Level: {{ store.level() }}</p>
    <p>XP: {{ store.xp() }}</p>
    <p>Gold: {{ store.gold() }}</p>
    <button (click)="onEnemyDefeated()">Defeat Enemy</button>
  `,
  providers: [HeroStore],
})
export default class HeroPanelComponent {
  private readonly store = inject(HeroStore);
  private readonly dispatch = injectDispatch(heroPanelEvents);

  onEnemyDefeated() {
    // We're just telling the app an enemy was defeated, providing the XP and gold.
    // The store and effect will handle the rest!
    this.dispatch.enemyDefeated({ xpValue: 200, goldDrop: 50 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how clean this is. The component doesn't need to know how to update the XP, gold, or level; it just dispatches an event. It says, "Hey, an enemy was defeated with 200 XP and 50 gold." The HeroStore then listens, and the HeroPanelComponent automatically updates its display because it's reading the state from a signal (store.xp(), store.level(), etc.).

This approach gives you the power to create a robust and well-structured application, just like a well-designed game. The next time you're building something complex, think about how events could make your life easier.

Top comments (0)