Hacking Angular Signals
Evgeniy OZ
Posted on February 20, 2024
Let’s check out what Angular Signals have under the hood, and how we can use it for extending their functionality and debugging.
What are Angular Signals
Angular Signals are functions with methods:
const $val = signal(0);
// function:
const value = $val();
// object:
$val.set(4);
$val.update((v) => v * 2);
How is this possible? Because in JavaScript, functions are objects:
// declaring a function
const double = (val: number) => val * 2;
// adding a method
double.isEven = (number: number) => number % 2 === 0;
// use as object
double.isEven(15) // false
// use as function
double(8) // 16
How can we use it?
We can:
- add new methods;
- override methods set() and update() to intercept modifications;
- read internal state variables and methods of signal;
- create a proxy signal that will intercept reads.
Extending our Signals
Sometimes you need a variable to be a Signal, but some parts of your code would like to use it as an Observable instead.
Fortunately, interfaces of Signals and Observables have no collisions in method names. So let’s create a hybrid:
export function toObservableSignal<T>(
s: Signal<T>,
options?: ToObservableOptions
) {
if (isDevMode() && !options?.injector) {
assertInInjectionContext(toObservableSignal);
}
// create an observable
const obs = toObservable(s, options);
// add methods of observable to our signal object
for (const obsKey in obs) {
(s as any)[obsKey] = (obs as any)[obsKey];
}
return s;
}
Usage example:
@Component({
//...
template: `
<h4>Signal A: {{ a() }}</h4>
<h4>Observable A: {{a|async}}</h4>
<h4>Signal C (computed() A*3): {{c()}}</h4>
{{quote()}}
`,
})
export class App {
a = toObservableSignal(signal<number>(1));
// use as Observable
b = this.a.pipe(
debounceTime(500),
distinctUntilChanged(),
switchMap((v) => this.http.get('https://dummyjson.com/quotes/' + v))
);
// use as Signal
c = computed(() => this.a() * 3);
quote = toSignal(this.b);
increment() {
// "a" will not stop being a Signal after
// we used it as an Observable
this.a.update((v) => v + 1);
}
decrement() {
this.a.update((v) => Math.max(1, v - 1));
}
}
You can find this function in the NG Extension Platform: documentation.
This function can be improved in multiple ways, so your pull requests are welcomed!
You can extend Signals with any functions you want, your fantasy here has almost no limitations: just don’t use names set
, update
, and asReadonly
.
Overriding existing methods
Let’s say that we want to intercept writes to our Signal to transform input values, duplicate information somewhere else, or just for debugging.
function skipNonEvenNumbers(s: WritableSignal<number>) {
const srcSet = s.set; // we need the source method to avoid recursion
s.set = (value: number) => {
if (value % 2 !== 0) {
console.warn('[set] skipping:', value);
return;
}
console.log('[set]:', value);
srcSet(value);
};
s.update = (updateFn: (value: number) => number) => {
const value = updateFn(s());
if (value % 2 !== 0) {
console.warn('[update] skipping:', value);
return;
}
console.log('[update]:', value);
srcSet(value);
};
}
Usage example.
This trick is being used in Reactive Storage: getWritableSignal().
Inside a Signal
Angular Signal is not just a function, it is an object. This object has a hidden field, SIGNAL
, which contains some interesting data and functions. I’m glad we have it, and I hope you will not abuse it. Because the tricks above were “a little bit hacky”, and the tricks below are too filthy to use for anything but debugging, creating dev tools, and fun.
Every Angular Signal extends ReactiveNode:
// packages/core/primitives/signals/src/graph.ts
export const REACTIVE_NODE: ReactiveNode = {
version: 0 as Version,
lastCleanEpoch: 0 as Version,
dirty: false,
producerNode: undefined,
producerLastReadVersion: undefined,
producerIndexOfThis: undefined,
nextProducerIndex: 0,
liveConsumerNode: undefined,
liveConsumerIndexOfThis: undefined,
consumerAllowSignalWrites: false,
consumerIsAlwaysLive: false,
producerMustRecompute: () => false,
producerRecomputeValue: () => {},
consumerMarkedDirty: () => {},
consumerOnSignalRead: () => {},
};
this way:
// packages/core/primitives/signals/src/signal.ts
export const SIGNAL_NODE = {
...REACTIVE_NODE,
equal: defaultEquals,
value: undefined,
}
But an instantiated Signal object does not contain all of this directly. All of them are hidden under the field that uses a Symbol as a name:
// packages/core/primitives/signals/src/signal.ts
export function createSignal<T>(initialValue: T): SignalGetter<T> {
const node: SignalNode<T> = Object.create(SIGNAL_NODE);
node.value = initialValue;
const getter = (() => {
producerAccessed(node);
return node.value;
}) as SignalGetter<T>;
// next line adds a SignalNode to the field SIGNAL:
(getter as any)[SIGNAL] = node;
return getter;
}
So if you have a signal $value and you access SIGNAL
field, then you’ll get all of the fields that SIGNAL_NODE
has.
How can we use it?
We can read fields and override methods to intercept access and use it for debugging or to create fancy tools that illustrate what is going on inside a Signal and render the Dependency Graph.
We can even convert some fields to accessors:
function getSignalVersion<T>(s: WritableSignal<T>): Signal<number> {
const node = s[SIGNAL];
const $version = signal(0);
Object.defineProperty(node, 'version', {
get: () => {
const v = untracked($version);
console.log('🟢 reading:', v);
return v;
},
set: (v) => {
untracked(() => $version.set(v));
console.log('🔴 writing:', v);
},
});
return $version.asReadonly();
}
Or create a proxy that can watch Signal reads without effect()
:
function watchSignalReads<T, M extends Signal<T> | WritableSignal<T>>(s: M): M {
const node = s[SIGNAL];
const newGetter = () => {
const value = s();
console.log('Read:', value);
return value;
};
(newGetter as any)[SIGNAL] = node;
if (s.hasOwnProperty('set')) {
const w = s as WritableSignal<T>;
newGetter.set = w.set;
newGetter.update = w.update;
newGetter.asReadonly = w.asReadonly;
}
return newGetter as M;
}
Usage example.
Again, I hope you won’t even consider using this in “production,” and instead, I hope you’ll utilize it to create some amazing tools, gain recognition, and contribute to enriching the Angular ecosystem 😎
🪽 Do you like this article? Share it and let it fly! 🛸
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
Posted on February 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.