DEV Community

Cover image for Mastering Angular Signals: Understanding Agular Signal Events (part 2)
Romain Geffrault
Romain Geffrault

Posted on

Mastering Angular Signals: Understanding Agular Signal Events (part 2)

If you haven’t already read the first part of this series, you may want to take a look at it before reading this article.
Here is the link

How to Implement Signal Events and State Reactions

I think this is the least obvious use case for Signals.

But Signals can indeed be used to implement something close to an Event‑Driven Architecture (with some limitations).

This article focuses on how a signal state can be affected by a signal event.

When I first shared my thoughts on this idea, almost nobody believed it.

So let me show you how Signals can achieve this — and what their limitations are.

At first, it wasn’t obvious to find this pattern; I haven’t seen anyone else use Signals this way.

So it’s still experimental — but my first tests are awesome! 🚀

Event‑Driven Architecture with Signals Event — Principles

There are multiple ways to modify a signal state based on a signal event.

Here, I will show you my favorite technique (which can certainly be improved).

As mentioned earlier, this technique is inspired by the resource API.

When you pass an undefined value to a resource parameter (params), it does not trigger the loader.

I decided to push this concept further — and that’s what enables the event‑driven mechanism.

The idea is simple: if a Signal returns undefined, it should not react to it.

In RxJS, this is similar to an observable returning EMPTY, which pauses the flow.

To react to a signal event, we need to use an effect.

As we will see, not all patterns can be handled using a linkedSignal.

How to Implement an Event Mechanism Using Signals

// Event signal
const myEvent = signal<{} | undefined>(undefined);

// State signal
const myState = signal(0);

// Effect reacting to the event
effect(() => {
  if (!myEvent()) {
    return;
  }
  myState.update(value => value + 3);
});
Enter fullscreen mode Exit fullscreen mode

Each time myEvent is triggered with myEvent.set({}) or myEvent.update(() => ({})), myState will increment by 3.

Since myEvent() returns undefined on first read, the initialization won’t increment myState.

So we have a relatively simple system.

However, this pattern is a bit weak if myEvent is shared across the app.

If some part of the app starts listening to myEvent after it has already been triggered, it will still execute the effect.

So let me introduce a utility function called source, which solves this problem.

Implementing a source Utility Function

source ensures that listeners always receive undefined as the first value and skip any previous emissions.

It can certainly be improved, but here is my implementation, where I added options such as preserveLastValue.

// Utility signal creation with improved event handling
import { linkedSignal, Signal, signal, ValueEqualityFn } from "@angular/core";

export interface Source<T> extends Signal<T | undefined> {
  set: (value: T) => void;
  preserveLastValue: Signal<T | undefined>;
}

export function source<T>(options?: {
  equal?: ValueEqualityFn<NoInfer<T> | undefined>;
  debugName?: string;
}): Source<T> {
  const sourceState = signal<T | undefined>(undefined, {
    ...(options?.equal && { equal: options.equal }),
    ...(options?.debugName && { debugName: options.debugName + "_sourceState" }),
  });

  const listener = (listenerOptions: { nullishFirstValue?: boolean }) =>
    linkedSignal<T, T | undefined>({
      source: sourceState as Signal<T>,
      computation: (currentSourceState, previousData) => {
        if (!previousData && listenerOptions?.nullishFirstValue !== false) {
          return undefined;
        }
        return currentSourceState;
      },
      ...(options?.equal && { equal: options.equal }),
      ...(options?.debugName && { debugName: options.debugName }),
    });

  return Object.assign(
    listener({ nullishFirstValue: true }),
    {
      preserveLastValue: listener({ nullishFirstValue: false }),
      set: sourceState.set,
    }
  ) as Source<T>;
}
Enter fullscreen mode Exit fullscreen mode

Here are some related tests:

// Tests for the source utility
describe("source", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.resetAllMocks();
  });

  it("should generate a source that emits values and allows listeners to receive them", () => {
    const mySource = source<string>();
    expectTypeOf(mySource).toEqualTypeOf<Source<string>>();

    const myListener = computed(() => mySource());

    expect(myListener()).toBe(undefined);

    mySource.set("Hello World");
    expect(myListener()).toBe("Hello World");

    mySource.set("Hello Ng‑Query");
    expect(myListener()).toBe("Hello Ng‑Query");
  });

  it("A late listener should not receive previously emitted values", () => {
    const mySource = source<string>();

    mySource.set("Hello World");

    const myListener = computed(() => mySource());
    expect(myListener()).toBe(undefined);

    mySource.set("Hello Ng‑Query v2");
    expect(myListener()).toBe("Hello Ng‑Query v2");
  });

  it('A late listener should receive the last value when using "preserveLastValue"', () => {
    const mySource = source<string>();

    mySource.set("Hello World");

    const myListener = computed(() => mySource.preserveLastValue());
    expect(myListener()).toBe("Hello World");

    mySource.set("Hello Ng‑Query v2");
    expect(myListener()).toBe("Hello Ng‑Query v2");
  });
});
Enter fullscreen mode Exit fullscreen mode

Now that we covered the theory, let’s look at a practical use case.

A Practical Use Case for Signal‑Based Event Architecture

In my previous article, I shared a diagram where certain states need to react to a userLogout event.

Let's implement the reset logic when userLogout is triggered:

// Event signal
const userLogout = source<{}>();

@Injectable({ providedIn: "root" })
export class ProductsStates {
  private readonly allProducts = signal([] as Product[]);
  private readonly favoriteProducts = signal([] as Product[]);
  private readonly suggestionsProducts = signal([] as Product[]);

  private readonly _resetOnLogoutEffect = effect(() => {
    if (!userLogout()) {
      return;
    }
    this.favoriteProducts.set([]);
    this.suggestionsProducts.set([]);
  });

  // Other updaters...
}

@Injectable({ providedIn: "root" })
export class UserCartState {
  private readonly cart = signal([] as Cart[]);

  private readonly _resetCartOnLogoutEffect = effect(() => {
    if (!userLogout()) {
      return;
    }
    this.cart.set([]);
  });

  // Other updaters...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, when userLogout is triggered, it resets parts of the products and cart states in a declarative way (at the store level).

Even if there are multiple ways to handle user logout, this pattern is useful when writing declarative state updates.

You may wonder if this is truly useful. For my personal use, it absolutely is. At the end of this article, I will show an example based on one of my tools that relies completely on this pattern to create 100% declarative states.

Before discussing limitations, there is another pattern that allows reacting to signal events without using any effect.

How to implement declarative state with a linkedSignal that reacts to multiple Signal events?

Coming soon... 🎁

Follow me on LinkedIn for an Angular Advent calendar.
👉 Romain Geffrault

Top comments (0)