Signals - In depth
Continuing from the previous post where we discussed about the why of the signal, this post is about the how.
Signals is like a "sherlock holmes" who sits with a magnifying glass and tracks where the state is used throughout the application. The framework optimizes rendering updates accordingly.
Since signals is like a box, we need to unlock & open the box by invoking the signals to get the value. The moment we "invoke", angular tracks where all the signal is used throughout the app.
Signals have 2 flavours
the read only signals ( we can only read the value from the box, but cannot put anything into it )
the one which can be written into ( WritableSignals).
import { Component, signal } from '@angular/core';
// here, we have to invoke the signal to extract it's value
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
`,
})
export class AppComponent {
// by default, this is a writable signal
// hover to see the type WritableSignal<number>
counter = signal(0);
}
to update the value, we have 2 flavors here as well. If it is just about updating, we call call the set()
method, or if we want to make use of the previous state and act upon it, we have a function syntax using update()
.
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
<button (click)="setCounter()"> Set to 10</button>
<button (click)="incrementByOne()"> Increment by 1</button>
`,
})
export class AppComponent {
counter = signal(0);
setCounter() {
this.counter.set(10);
}
incrementByOne() {
this.counter.update((currentVal) => currentVal + 1);
}
}
Computed Signals
First and foremost, they are read only. They derive the value from other signals. So let's say, if we have a condition where we need to mark the counter as "even" or "odd", we don't need to create another signal, instead, we can derive from another signal. Also, they are lazily evaluated and memoized.
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
<p> {{ status() === true ? 'Even': 'Odd'}} </p>
<button (click)="incrementByOne()"> Increment by 1</button>
`,
})
export class AppComponent {
counter = signal(0);
// status is derived from the current value of counter.
// Whenever counter changes, status changes based on it.
status = computed(() => this.counter() % 2 === 0);
incrementByOne() {
this.counter.update((currentVal) => currentVal + 1);
}
}
now lets see the point where signals is lazy
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
`,
})
export class AppComponent {
counter = signal(0);
// if you run this, you wont see the console log.
// Why? because, there is nowhere we have used status()
// to invoke and extract the signal either in code or in template
status = computed(() => {
console.log('Called computed signal');
return this.counter() % 2 === 0;
});
}
now, let's see the caching point. Here, we have invoked the signal in template. So when the component loads, we see the console log once. If we click the first button , we see the console log has been triggered. But if we bang the second button N number of times, there won't be a second console because the computed value with 2 is already calculated and cached, and if someone is asking for the same value, then its given from cache so that the same is not recomputed unnecessarily.
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
<!-- invoked -->
<h3> {{ status() }} </h3>
<!-- Set the value to 2 and caches it -->
<button (click)="incrementByNumber(2)"> Set to 2 </button>
<!-- Since already in cache, it wont run the computed signal again -->
<button (click)="incrementByNumber(2)"> Set to 2 </button>
`,
})
export class AppComponent {
counter = signal(0);
status = computed(() => {
console.log('Called computed signal');
return this.counter() % 2 === 0;
});
incrementByNumber(value: number) {
this.counter.set(value);
}
}
Computed signals are dynamic.
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1> {{ counter()}} </h1>
<h3> {{ status() }} </h3>
<button (click)="incrementByNumber(1)"> + </button>
<button (click)="trackCounter()"> Track </button>
`,
})
export class AppComponent {
counter = signal(0);
isSelected = signal(false);
status = computed(() => {
if (this.isSelected()) {
console.log(this.counter() + 'is being tracked.');
return 'Counter is tracked';
}
return 'Default value';
});
incrementByNumber(value: number) {
this.counter.update((value) => value + 1);
}
trackCounter() {
this.isSelected.set(true);
}
}
here, unless the isSelected is true, the counter wont be tracked. But once we toggle the value, from that point onwards, every time counter changes, the same is reflected in the console.
Now, lets shift gears one level up.
With change detection, we have a special mode called "OnPush" which helps reduce the re-render cycles by applying certain conditions upon which the component re-renders. If we use them wisely, we can skip a part of the component tree from this constant checking process. Angular is smart enough to know that if we use signals with a component that is OnPush, whenever the value of the signal changes, angular marks the component so that next time the change detection runs, it picks up the latest value & keeps the same in sync.
Effect.
That is an operation that runs when one or more signal values change. If you ever tried react, there is a "useEffect" which comes somewhere near this concept and is more used for synchronization purposes.
effect(() => {
// whenever count gets a new value, this effect is triggered.
console.log(this.count());
})
Effects runs at least once. And they know when the signal used within changes. Whenever that change happens, effects runs again. Effects execute asynchronously.
The docs says, we need to use effects sparingly. Use them for below purposes.
Like logging, analytics, debugging.
Keeping data in sync with local storage
Adding custom behaviour that cannot be expressed with template syntax.
perform custom rendering to 3rd party libraries.
Note:
Do not use them in cases for propagating state changes. ie, "Hey, state just changed, so something because of that " - For such stuff, effect is not the go to thing. As a precaution, angular doesn't allow setting signals from effects. For absolute necessary occasions, angular opens up a backdoor and let us do that with "allowSignalWrites" set to true.
if we want to model state depending on another state, the best bet is to use "computed" signals.
Injection context
we cannot just sprinkle effects anywhere within component. We can only create an effect within an injection context where there is access to the inject function. The default and easiest way to satisfy this is to call the effect within constructor.
I remember this using the arrow function example in javascript. Sometimes, when the typical traditional function looses a context for "this", we used to do the
.bind(this)
to explicitly set the value ofthis
. Something similar is happening here too.
export class MyComponent {
readonly counter = signal(0);
// cannot use effects like this ❌
/*
effect(() => {
console.log(this.counter())
});
*/
// but assigning to a field is possible. ✅
private counterEffect = effect(() => {
console.log(this.counter())
})
constructor() {
// instead, we need to use them at a place where the
// component has access to the "injection context". Here, its within
// constructor. ✅
effect(() => {
console.log(this.counter())
});
}
}
or, if for whatever reason, we need to create an effect outside the constructor, dependency inject the Injector within constructor, so that we can explicitly pass the injection context into the effect.
constructor(private injector: Injector) {}
logMethod() {
effect(() => {
console.log(this.counter())
}, {
injector: this.injector }
);
}
When we create an effect,
it is auto destroyed when enclosing context is destroyed. ( aka, component is destroyed )
It does offer manual cleanup too should the need arises.
Few points to remember:
We can provide an equality function which can be used to check if the new value is different from old one.
We can also pick and choose to run the effect. If , for whatever reason, we have 2 signals as dependencies and we only need to re-run the effect when only one of them changes, that too is possible. We can use the untrack
method for that. Below, we say, when counter changes, hold on, don't run the effect. But if user changes, then go ahead.
effect(() => {
console.log(`${user()} - ${untracked(counter)}`);
});
Cleaning up effects
effect((onCleanup) => {
const timerId = setTimeout(() => {
// something asynchronous here..
}, 1000);
onCleanup(() => {
clearTimeout(timerId);
});
});
The onCleanup
function helps us register a callback. Before the next effect runs, the callback is execute first. That way, whatever logic within the callback runs, and may it be an unsubscribe()
, a timer cleanup, whatever it is, that runs. Here also, if you have tried react before, there is a callback function that the useEffect
provides with, which does a similar thing.
Still here? And still enjoying the post? Drop me your feedbacks, it means a lot.
More signal stuff on the next post.
Subscribe to my newsletter
Read articles from Canadian Dev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Canadian Dev
Canadian Dev
Front end engineer, Angular Trainer, Blogger, and blah.