Enhancing Angular Signals
Passionate about frontend tooling, monorepos, and developer experience.
A Reactive Primitive
Signals have been introduced into Angular in version 16 (although initially in developer-preview) as the fundamental building block for coupling state management with the rendering process of the framework. In version 18 we might finally see the fruits of this labor.
Beforehand, Angular was a pretty imperative framework with a lot of magic happening under the hood. Aká the famous Change Detection.
Whenever a property in a component changed, Angular would have to perform a lot of checks to detect where the change happened and update the view accordingly. This meant that Angular did not need to know much about the state of the application, but it also meant that it was not very efficient. This is where signals come into play.
A signal is a reactive primitive that tells Angular where to look for changes in the application instead of progressively searching through the entire component tree. This makes Angular more efficient and allows for better performance in large applications.
Okay, maybe this is a bit of a far stretch, because the Angular of today does not yet leverage signals to their full potential for rendering and change detection. But this is the de facto direction Angular is heading towards and signals are a great building block for that.
The basic building blocks of a signal contain the getter, set
, update
, computed
and effect
functions.
The imperative nature of signals
Although signals are a great building block for state management and the coupling of state and rendering is a necessity for performance, there are some downsides to plain signals.
The main issue is that signals have a set
and update
function that can be called from anywhere in the application.
This can lead to a lot of confusion and bugs if not used correctly.
The reason for that is, that these functions encourage a highly imperative coding style, because the kind of action performed by calling set
or update
is not clear at all.
Therefore, updating state using plain signals is always imperative and hard to reason about.
Therefore, we should aim to make signals more declarative and less imperative. One way to do so is to use computed
a lot to derive state because it returns a Signal<T>
which has no set
or update
function in contrast to WritableSignal<T>
.
This is the preferred way to derive state in Angular signals, but not all state is derived...
This is where reducerSignal
, a custom signal wrapper, comes into play.
reducerSignal
and readonly signals
I created a custom signal wrapper called reducerSignal
that only exposes the actions defined in the reducers object.
These actions are the only way to update the state of the signal, which makes the signal more declarative and less imperative.
This is a great way to encapsulate state and make it more predictable and easier to reason about.
const mySignal = reducerSignal(0, {
increment: (state: number) => state + 1,
decrement: (state: number) => state - 1
});
mySignal.increment();
// mySignal.set(69); <-- Error
// mySignal.update(s => s + 420); <-- Error
The great thing about reducerSignal
is that it only exposes the actions defined in the reducers object instead of the set
and update
functions.
Therefore, it is directly obvious how the state updates at the point of declaration; giving context to the state change flow.
On a side note, this is not 100% declarative, more like a safer imperative way to update signals. But it is a step in the right direction more towards declarative code with less unpredictable state changes.
Implementation of reducerSignal
import {computed, Signal, signal} from '@angular/core';
export type Reducer<T, TPayload = any> = (state: T, payload?: TPayload) => T;
type Reducers<T> = {
[K: string]: Reducer<T, any>;
}
type ActionSignal<T, TPayload extends Reducers<T>> = Signal<T> & {
[K in keyof TPayload]: TPayload[K] extends (state: T, payload: infer P) =>
T
? (payload?: P) => void
: (payload?: Parameters<TPayload[K]>[1]) => void;
}
export function reducerSignal<T, TPayload extends Reducers<T>>(
initialValue: T, reducers: TPayload
): ActionSignal<T, TPayload> {
const writableSignal = signal(initialValue);
const readonlySignal = computed(() => writableSignal());
for (const [key, reducer] of Object.entries(reducers)) {
Object.defineProperty(readonlySignal, key, {
value: (payload?: any) => {
writableSignal.set(reducer(readonlySignal(), payload));
}
});
}
return readonlySignal as ActionSignal<T, TPayload>;
}
Okay, here we go with explaining the implementation of reducerSignal
. First of all, lets forget about the types because they are not that important for the understanding of the implementation and focus on the logic instead.
We basically pass an initial value and a reducers object to the reducerSignal
function.
Inside the function, we create a writable signal with the initial value and a readonly signal that is derived from the writable signal.
This readonly signal is really important because it has no set
or update
function and therefore cannot be changed from the outside, but it is derived from the writable signal.
We can either use computed()
or .asReadonly()
to derive a readonly signal from a writable signal.
Then we iterate over the reducers object and patch the readonly signal with actions that call the reducer with the current state and the payload. These actions are the only way to update the state of the signal, which makes the signal more declarative and less imperative.
Okay, this was a great way to have safer state updates with signals, but what about other enhancements? Let's take a look at a localStorageSignal
.
Persisting Signals with localStorageSignal
Sometimes we want to persist state in the localStorage
to keep the state between page reloads.
This is a common use case for many applications.
We might want to persist data like the user's theme, language, or other settings.
In order to synchronize a signal with the localStorage
, we could create an effect and write to the local storage whenever the signal changes.
BUT this might be difficult, because a signal always has an initial value and the effect would always write the initial value to the localStorage
when the effect is created - which could cause race conditions between the write inside the effect and an initial read to retrieve the stored value.
A better solution is to create yet another signal wrapper function called localStorageSignal
that persists the state of a signal in the localStorage
.
Which can be used like this:
const themeSignal = localStorageSignal(UserTheme.LIGHT, 'theme');
console.log(themeSignal());
// --> UserTheme.DARK, because it was retrieved from localStorage
Implementation of localStorageSignal
import {signal, WritableSignal} from '@angular/core';
export function localStorageSignal<T>(
initialValue: T,
localStorageKey: string
): WritableSignal<T> {
const storedValueRaw = localStorage.getItem(localStorageKey);
if (storedValueRaw) {
try {
initialValue = JSON.parse(storedValueRaw);
} catch (e) {
console.error('Failed to parse stored value for key:', localStorageKey);
}
} else {
localStorage.setItem(localStorageKey, JSON.stringify(initialValue));
}
const writableSignal = signal(initialValue);
// monkey-patch the set method to update the localStorage value
const originalSet = writableSignal.set;
writableSignal.set = (value: T) => {
localStorage.setItem
(localStorageKey, JSON.stringify(value));
originalSet(value);
};
return writableSignal;
}
This implementation is a lot more straight forward than the reducerSignal
implementation.
Here we first try to retrieve the value from the localStorage
and parse it. If it fails (in case there is no value in the localstorage or a wrong one), we log an error and use the initial value instead.
Then we can monkey-patch the set function of the writable signal to also update the localStorage
value whenever the state of the signal changes.
If you did not know, monkey-patching is a technique to change the behavior of a function at runtime without changing the original function.
We basically just attach some additional behavior to the original function.
In our case we attach the localStorage.setItem
function to the set
function of the writable signal.
Now we have a signal that is synchronized with the localStorage
and can be used to persist state between page reloads.
Conclusion
Signals are a reactive primitive, that is great for attaching state management to the rendering process of Angular.
But some of the signal APIs are too low-level and imperative for application development because they usage of set
and update
functions without a lot of care and self-discipline can lead to a lot of confusion and bugs.
That is because the state of the signal can change in unpredictable ways.
A solution to this problem is to implement a reducerSignal
which only exposes the actions defined in the reducers object and drops the set
and update
functions.
This is a great way to encapsulate state and make it more predictable and easier to reason about.
Enhancing these low-level APIs with custom signal wrappers generally seems like a good idea.
Another example is the localStorageSignal
which persists the state of a signal in the localStorage
by patching the internal set function to also sync the local storage.
Note, that both of the implementations are far from perfect and can be improved in many ways. But they are a good starting point to demonstrate the idea of enhancing Angular signals with custom signal wrappers. Take this as an inspiration to create your own custom signal wrappers and share them with the community.
"Opinions are my own and not the views of my employer."