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);
});
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>;
}
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");
});
});
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...
}
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();
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(...), andmodel(...) - Listeners/consumers include
computed,effect,linkedSignal(...)(because they can depend on signals), and component inputsinput(...) - A consumer may depend on multiple signals that can be producers or other consumers, such as:
computed(() => signalNumber1() + signalNumber2());
And these sources may change within the same "cycle":
update() {
// 👇 producer
signalNumber1.set(...);
// 👇 producer
signalNumber2.set(...);
}
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.HasChildViewsToRefreshso 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
- Consumers recalculate their values only when read (by the template /
This results in two distinct phases:
- Push phase — Propagates "dirty" state synchronously through the reactive graph
- 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 aftersignal.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);
}
Here is a demo showing that maxValue increases only by 1 (instead of 1111)
Running the demo shows that consumers only receive:
currentMaxValue + 1
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);
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:
- If
incrementanddecrementboth 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.
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({});
}
When source2 changes before source1 during the same cycle, which effect logs first?
“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
linkedSignalvalue - 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);
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>;
};
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"
);
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>;
}
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,
}),
});
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>;
}
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.
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)