DEV Community

Cover image for Mastering Angular Signals: Understanding Angular Signal Recomputation Reactions (part 2)
Romain Geffrault
Romain Geffrault

Posted on • Edited on

Mastering Angular Signals: Understanding Angular Signal Recomputation Reactions (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 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) — a Recomputation Event‑Driven Architecture.

This article focuses on how a signal state can be affected by a signal recomputation 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! 🚀

Recomputation‑Event‑Driven Architecture with Signal Reactions — Principles

There are multiple ways to modify a signal state based on a signal recomputation 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 recomputation‑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 recomputation event, we need to use an effect.

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

How to Implement a Recomputation Event Mechanism Using Signals

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

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

// Effect reacting to the recomputation 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 the 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 recomputation 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 sets 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 set 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 have covered the theory, let’s look at a practical use case.

A Practical Use Case for Signal Reaction Architecture

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

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

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 recomputation events without using any effect.

How to Implement Declarative State and Its Reactions Using linkedSignal?

There is another technique that lets us react whenever a signal recomputation event is "computed".

To detect which signal recomputation event is triggered, we can use a linkedSignal.

We map all the reaction events in the linkedSignal source, then compare the previous recomputation event value with the new one in the computed section.

Here is an implementation of this pattern:

  // This block sets up increment/decrement sources and links them to a declarative counter.
  protected readonly increment = source<{}>();
  protected readonly decrement = source<{}>();

  protected readonly counter = linkedSignal<
    {
      increment: {} | undefined;
      decrement: {} | undefined;
    },
    number
  >({
    source: () => ({
      increment: this.increment(),
      decrement: this.decrement(),
    }),
    computation: (currentSource, previous) => {
      if (!previous || previous.value === undefined) {
        return 0;
      }

      if (currentSource.increment !== previous.source.increment) {
        return previous.value + 1;
      }

      if (currentSource.decrement !== previous.source.decrement) {
        return previous.value - 1;
      }

      return previous.value;
    },
  }).asReadonly();
Enter fullscreen mode Exit fullscreen mode

As you can see, this enables you to write fully declarative state logic without any effects.

Here is the demo of this signal‑recomputation‑event approach with linkedSignal.

How does it work?

By passing a reaction recomputation event map to the linkedSignal source, we can check whether the value of any signal source has changed.

If it changes, it means a signal recomputation event has been "recomputed".

I will soon share another article where I implement a simple server‑state utility based on this principle, which allows:

  • tracking async state (using resources)
  • optimistic updates / updates on success / fallback on error
  • all in a declarative way.

Now, let's discuss the real limitations of signal recomputation reaction events that you should be aware of when using this mechanism.

Even this simple example can hide a design issue 😅

What Are the Limitations of Signal Reactions?

When implementing a Signal Recomputation Event Architecture, you should be aware of 5 major behaviors that are not (always) avoidable.

1 - Reactions Are Not Fully Synchronous When a Signal Changes

When a signal (or source) is updated using source.set(...) or source.update(...), recomputation reactions are not immediate. They are not fully synchronous.

As shown in this demo. It compares Observable reactions to Signal reactions:

  • Observable reactions are fully synchronous
  • Signal reactions are not fully synchronous

Why Are Signal Reactions Not Fully Synchronous?

I will try to explain this with my own words. If I make any mistakes, please correct me.

This is due to the internal pull‑based mechanism of Signals.

This behavior is intentional and offers several benefits.

  • All writable signals are considered as producers (meaning they can be changed imperatively). This includes signal(...), linkedSignal(...), and model(...)
  • Listeners/consumers include computed, effect, linkedSignal(...) (because they can depend on signals), and component inputs input(...)
  • A consumer may depend on multiple signals that can be producers or other consumers, such as:
computed(() => signalNumber1() + signalNumber2());
Enter fullscreen mode Exit fullscreen mode

And these sources may change within the same "cycle":

update() {
  // 👇 producer
  signalNumber1.set(...);
  // 👇 producer
  signalNumber2.set(...);
}
Enter fullscreen mode Exit fullscreen mode

Without the pull mechanism, this computed callback would run twice.

This is a simple case. But some signals can depend on other signals, etc., forming a full dependency graph.

And the template would re-render every time the computed runs.

(This is not entirely true because template rendering also depends on the ChangeDetectionScheduler.)

But the main issue arises when passing a signal to a child component input.

Without the pull behavior, a child component input would see every intermediate value and may emit outputs based on them.

This could trigger additional cycles, potentially long to resolve (or worse, infinite).

How the Pull Mechanism Works

Indeed, there are 2 phases:

Push Phase (Synchronous):

  • When a signal/producer is written, its value is updated
  • The signal is marked as dirty and its version is incremented
  • All consumers (computed signals, effects, templates) are marked as dirty
  • All parent components up to the root are marked LViewFlags.HasChildViewsToRefresh so Angular knows which branches of the component tree need to be checked during change detection
  • The scheduler is notified and schedules a tick (if not already scheduled). The scheduled tick takes a few milliseconds before being executed, which allows multiple signal changes to be coalesced into a single change detection cycle

No callbacks are executed at this point — only propagation of "dirty" flags

Pull Phase (Lazy evaluation):

  • Angular schedules change detection via microtask or RAF (requestAnimationFrame)
  • When the tick executes:
    • Consumers recalculate their values only when read (by the template / effect)
    • Templates refresh and read the latest signal values
    • DOM is updated with the new values
    • Effects are scheduled and executed

This results in two distinct phases:

  1. Push phase — Propagates "dirty" state synchronously through the reactive graph
  2. Pull phase — Lazily recomputes values and updates the DOM when change detection runs

Important: If a derived signal (computed, linkedSignal, etc.) is read during the push phase (e.g., in synchronous code after signal.set()), it will immediately execute its callback and recompute its value before change detection runs. The pull mechanism is triggered whenever a signal is read, not just during change detection.

Now, let's describe practical limitations.

2 - Reactions React Only to the Last Produced Value

If a signal is updated multiple times within the same push phase (set or update).

Consumer callbacks (computed / effect / linkedSignal / input / model) will be triggered only once, with the final value.

There is a signal producer numberSource and two consumers that catch the maximum propagated value.

The source is updated like this:

changeSourceValue() {
  const currentMaxValue = this.maxValue();

  this.numberSource.set(currentMaxValue + 1000);
  this.numberSource.set(currentMaxValue + 100);
  this.numberSource.set(currentMaxValue + 10);
  this.numberSource.set(currentMaxValue + 1);
}
Enter fullscreen mode Exit fullscreen mode

Here is a demo showing that maxValue increases only by 1 (instead of 1111)

Running the demo shows that consumers only receive:

currentMaxValue + 1
Enter fullscreen mode Exit fullscreen mode

This is important to keep in mind.

Signal consumers only get the last set value of a signal producer.

How to Catch All Values That Are Set During the Push Phase?

I created a utility function to capture all values set on a signal during the Push phase.

Example:

this.numberSource.set(currentMaxValue + 1000);
this.numberSource.set(currentMaxValue + 100);
this.numberSource.set(currentMaxValue + 10);
this.numberSource.set(currentMaxValue + 1);
Enter fullscreen mode Exit fullscreen mode

Instead of receiving only the last value, the consumer can receive all values as an array.

This is done with a custom stackedSource.

Each time set is called, it pushes the value to an internal array.
When this producer is read (during the Pull phase), it returns all captured values and clears the stack.

Here is the demo using stackedSource.

I do not know the drawbacks of this technique, as I do not currently need it.

Here is a way to capture all the producer values, but you cannot capture intermediate values of a signal consumer.

3 - Multiple Reactions May Occur Within the Same Cycle

Signal reactions are delayed until the end of the cycle (at the pull phase).

If multiple signal sources change in the same cycle, multiple reactions may occur.

In our linkedSignal example, the implementation is not designed to handle this scenario:

Signal recomputation event with linkedSignal limitation

  • If increment and decrement both update during the same cycle, only the increment reaction is processed

To solve this, instead of returning the reaction value directly, we propagate an internal value so that both reactions can be handled within the same cycle.

Signal event with linked signal

Now, increment and decrement changes can be handled in the same cycle.

Depending on your needs, you may have to adjust your pattern.

The order of reactions matters. Make sure that reactions within linkedSignals can be run in the same callback invocation.

4 - The Order of Effects Matters

effect also has limitations you should know.

Consider two effects watching two sources.

  source1 = source<{}>();
  source2 = source<{}>();

  _source1Effect = effect(() => {
    if (!this.source1()) return;
    console.log('Effect 1');
  });

  _source2Effect = effect(() => {
    if (!this.source2()) return;
    console.log('Effect 2');
  });

  update() {
    this.source2.set({}); // ! source2 change before source1
    this.source1.set({});
  }
Enter fullscreen mode Exit fullscreen mode

When source2 changes before source1 during the same cycle, which effect logs first?

Here is the reproduction.

“Effect 1” always logs before “Effect 2”.

The order of declaration determines execution order.

If you swap the declaration order, logs invert accordingly.

5 - Make Sure Your linkedSignal Is Read When Using It with Reactions

If your linkedSignal uses reactions, the reactions will only be triggered if this linkedSignal is read (during the cycle where the reaction is produced).

Otherwise, the linkedSignal will miss the value.

And worse, it will react to the last produced value when it is read again.

Here is a demo where you can see this behavior

  • use the toggle button to display/hide the linkedSignal value
  • use the increment button to produce a reaction
  • use the update button to update the value imperatively

Then, check what happens when the linkedSignal is hidden/visible.

Where Should You Not Use Signal Reactions?

From my experience, Signal Reactions are not well suited for handling certain types of event orchestration.

Since reactions can have multiple producer sources that change during the same cycle, you cannot know which one changed first.

It is still possible to use them, but you must handle this case arbitrarily.

For me, this is where Observables and RxJS are more relevant.

From my experience, Signal Reactions are not designed to handle event orchestration.

Take a Step Back from Signal Reactions

Even if my article encourages exploring Signal Reactions, it is still something relatively new.

This means that we do not yet have much knowledge about potential drawbacks that are not yet listed.

And Signal Reactions can turn into a “smell pattern”.

So take a step back, analyze, and experiment on your own.

Some Community Feedback ⚠️

Some Angular developers strongly discourage using Signals as events.

Because they can lead to unexpected behavior — and this is true.

I try to list all of them, but I may miss some, and not all developers may be aware of the drawbacks.

This can lead to an app with unexpected behavior.

My Point of View About Signal Reactions

I am still experimenting with Signal Reactions.

For now, I like using them at the low level of the app, when they deal with component DOM events (like a button click, the scroll reaching a target...).

So I keep using them in my personal projects for now, until I am confident with them.

Before ending with Signal Recomputation Reactions, I would like to share
some utility functions that may also help you.

Signal Recomputation utilities toSource / computedSource / sourceFromEvent

toSource transforms a Signal into a source

toSource creates a source from a signal (writable or not).

Usage:

const mySignal = signal("init");
const mySource = toSource(mySignal);
Enter fullscreen mode Exit fullscreen mode

Now, the first read of mySource from a new consumer will return
undefined.

Code:

export function toSource<SourceState, ComputedValue>(
  signalOrigin: Signal<SourceState> | WritableSignal<SourceState>,
  options?: {
    computed?: (sourceValue: NoInfer<SourceState>) => ComputedValue;
    equal?: ValueEqualityFn<NoInfer<SourceState> | undefined>;
    debugName?: string;
  }
): ReadonlySource<
  IsUnknown<ComputedValue> extends true ? SourceState : ComputedValue
> {
  const sourceState = linkedSignal<SourceState | undefined>(signalOrigin, {
    ...(options?.equal && { equal: options?.equal }), // add the equal function here, it may help to detect changes when using scalar values
    ...(options?.debugName && {
      debugName: options?.debugName + "_sourceState",
    }),
  });

  const listener = (listenerOptions: { nullishFirstValue?: boolean }) =>
    linkedSignal<SourceState, any>({
      source: sourceState as Signal<SourceState>,
      computation: (currentSourceState, previousData) => {
        // always return undefined when first listened
        if (!previousData && listenerOptions?.nullishFirstValue !== false) {
          return undefined;
        }
        //! use untracked to avoid the computed being re-evaluated when used inside another effect/computed
        return untracked(() =>
          options?.computed
            ? options?.computed?.(currentSourceState)
            : currentSourceState
        );
      },
      ...(options?.equal && { equal: options?.equal }),
      ...(options?.debugName && { debugName: options?.debugName }),
    });
  return Object.assign(
    listener({
      nullishFirstValue: true,
    }),
    {
      preserveLastValue: listener({
        nullishFirstValue: false,
      }),
    }
  ) as ReadonlySource<any>;
}

export type IsUnknown<T> = unknown extends T
  ? [T] extends [unknown]
    ? true
    : false
  : false;

export type ReadonlySource<T> = Signal<T | undefined> & {
  preserveLastValue: Signal<T | undefined>;
};
Enter fullscreen mode Exit fullscreen mode

computedSource use it to derive a source

Useful if you need to map your source value without affecting the
existing one.

Usage:

const mySignal = source<{ text: string }>();
const mySource = computedSource(
  mySignal,
  (sourceValue) => sourceValue.text + " Derived Source Text"
);
Enter fullscreen mode Exit fullscreen mode

Code:

export function computedSource<SourceState, ComputedValue>(
  signalOrigin: Source<SourceState> | ReadonlySource<SourceState>,
  computedFn: (sourceValue: NoInfer<SourceState>) => ComputedValue,
  options?: {
    equal?: ValueEqualityFn<NoInfer<ComputedValue> | undefined>;
    debugName?: string;
  }
): ReadonlySource<ComputedValue> {
  const listener = (listenerOptions: { nullishFirstValue?: boolean }) =>
    linkedSignal<SourceState, ComputedValue | undefined>({
      source: signalOrigin as Signal<SourceState>,
      computation: (currentSourceState, previousData) => {
        // always return undefined when first listened
        if (!previousData && listenerOptions?.nullishFirstValue !== false) {
          return undefined;
        }

        return computedFn(currentSourceState);
      },
      ...(options?.equal && { equal: options?.equal }),
      ...(options?.debugName && { debugName: options?.debugName }),
    });
  return Object.assign(
    listener({
      nullishFirstValue: true,
    }),
    {
      preserveLastValue: listener({
        nullishFirstValue: false,
      }),
    }
  ) as ReadonlySource<any>;
}
export type ReadonlySource<T> = Signal<T | undefined> & {
  preserveLastValue: Signal<T | undefined>;
};
export interface Source<T> extends Signal<T | undefined> {
  set: (value: T) => void;
  preserveLastValue: Signal<T | undefined>;
}
Enter fullscreen mode Exit fullscreen mode

sourceFromEvent derive a DOM event into a Signal source

Useful if you want to create a Signal source from a DOM event.

Each new consumer will get undefined as the first value.

Usage:

const buttonClickedSource = sourceFromEvent<MouseEvent>(button, "click");

const buttonClickedPointerPositionSource = sourceFromEvent(button, "click", {
  computedValue: (event: MouseEvent) => ({
    offsetX: event.offsetX,
    offsetY: event.offsetY,
  }),
});
Enter fullscreen mode Exit fullscreen mode

Remark: The computedValue callback is triggered each time an event is
emitted. (It may not be the best name.)

Code:

export type SourceFromEvent<T> = Source<T> & {
  dispose: () => void;
};

export function sourceFromEvent<T>(
  target: EventTarget,
  eventName: string,
  options?: {
    event?: boolean | AddEventListenerOptions;
    computedValue?: never;
    source: {
      equal?: ValueEqualityFn<NoInfer<T> | undefined>;
      debugName?: string;
    };
  }
): SourceFromEvent<T>;
export function sourceFromEvent<T, ComputedValue>(
  target: EventTarget,
  eventName: string,
  options?: {
    event?: boolean | AddEventListenerOptions;
    computedValue: (event: T) => ComputedValue;
    source?: {
      equal?: ValueEqualityFn<NoInfer<T> | undefined>;
      debugName?: string;
    };
  }
): SourceFromEvent<ComputedValue>;
export function sourceFromEvent(
  target: EventTarget,
  eventName: string,
  options?: {
    event?: boolean | AddEventListenerOptions;
    computedValue?: (event: Event) => unknown;
    source?: {
      equal?: ValueEqualityFn<NoInfer<unknown> | undefined>;
      debugName?: string;
    };
  }
): SourceFromEvent<unknown> {
  assertInInjectionContext(sourceFromEvent);
  const eventSignalSource = source<unknown>(options?.source);

  const listener = (event: Event) => {
    if (options?.computedValue) {
      const computed = options.computedValue(event);
      eventSignalSource.set(computed);
      return;
    }
    eventSignalSource.set(event);
  };

  target.addEventListener(eventName, listener, options?.event);

  const destroyRef = inject(DestroyRef);

  const dispose = () => {
    target.removeEventListener(eventName, listener, options?.event);
  };

  destroyRef.onDestroy(() => {
    dispose();
  });

  return Object.assign(eventSignalSource, {
    dispose,
  });
}

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

Using Signal Recomputation Reactions in declarative store

Here a sneakpick of my states management tool where I fully integrate all of this principles.

The store expose 2 states, counter and search.
When the recomputation is triggered after the reset source that has change, it will reset the counter and search value.

ng-craft source example

Acknowledgements

I would like to thank Lucas Garcia, who challenged me extensively on Signals, their internal mechanisms, and the way I communicate about them.
If there is only one thing to do after reading this article, it is to go and read his debates on linkedin (discord...). I learn a tremendous amount from the insights he shares—they are a real gold mine for being challenged and discovering new perspectives.

I would also like to thank Mathieu Riegler, who strongly emphasized that I should not associate Signals with events, so as not to confuse their proper usage (I hope I have at least partially succeeded on that front).

I would also like to thank the Angular team for introducing Signals. Their ease of use makes it hard to realize the amount of work happening under the hood. Congratulations for this remarkable achievement.

Finally, I would like to thank everyone who commented on my LinkedIn posts to share ideas, feedback, and constructive criticism.

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

Top comments (0)