It’s not a “comprehensive guide” and “all you need to know”, at least because Angular Signals are not completely released yet (we are still waiting for input()
and model()
functions), but I’ll share what I’ve learned after some weeks of extensive research.
🔍 What are we going to explore?
signal()
computed()
effect()
toSignal()
toObservable()
support of signals in the templates.
That’s part of Angular Signals API that we can already try.
⚖️ What can Angular Signals give us?
The most important thing is the new way of detecting changes in the templates. In the articles about Angular Signals, you’ll often see parts about Change Detection, and there is a reason for that.
A simple example:
<ul *ngIf="!isLoading">
<li *ngFor="let item of items">{{item}}</li>
</ul>
In this template, we have some “bindings” — when isLoading
or items
are modified, DOM should be modified also.
The question is: how Angular can know that bindings were modified?
There are 3 ways to do this:
ZoneJS + dirty checking (ChangeDetectionStrategy.Default);
Observables +
async
pipe (ChangeDetectionStrategy.OnPush);Signals.
The code of our example will work with the first (default) option.
With observables & OnPush:
<ul *ngIf="!isLoading$|async">
<li *ngFor="let item of items$|async">{{item}}</li>
</ul>
With signals:
<ul *ngIf="!isLoading()">
<li *ngFor="let item of items()">{{item}}</li>
</ul>
That’s the gist of it. Signals are a way of notifying Angular that some part of the page should be modified. It’s their most important task, their main usage, and they are promised to be the most effective in this.
In a “signals-only” Angular application we can remove ZoneJS and enjoy pure and granular change detection — but for that, all the libraries you use in your templates should also use only signals in their templates. That’s why Angular will support the existing change detection for a very long time, and that’s why you should not worry about the changes — it’s absolutely safe to just leave your existing code as is.
📑 API
signal()
creates a writeable signal. Supposed to be used in the code where you are going to write into this signal directly. They are not supposed to be exposed as part of the public API of your service or component.
If your component has its own (local) store (in a separate class), then it’s ok to declare some signal-containing fields as public
to let your component write to the signal directly — it’s the same as “patching” or updating the state of ComponentStore
or RxState
.
In shared stores (a.k.a “global stores“ and “feature stores”), it’s better to hide them behind getters and setters (or actions, or effects). Otherwise, they’ll become just a bunch of global variables, writeable by anyone. For example, if isLoading
field will be writeable by any part of your code, there are high chances to create an infinite spinner.
signal()
reveals the most important thing about Signals — they always have value. You can not create a signal and assign a value later. It also means, that if you need some source of data that will be produced later — you need an Observable, not a Signal.
You need an observable, not a Signal when you:
Care when the value will be emitted;
Cannot return a value instantly;
Care about the “completeness” — an observable can be “complete”, a signal lives forever;
Need to be notified about every change;
Want to filter or delay the moment when you emit the value.
DOM events, XHR requests, user’s input — you can easily debounce()
, concatMap()
, take(1)
, skip(2)
, forkJoin()
them, cancel requests with switchMap()
. If your source of data can not answer the question “What is the current value right now?” without any delay, then it can not be presented as a Signal.
Signals shine in a different field: representation of the application’s state. And it’s quite an important role!
computed()
creates a readonly derived signal from other signals. My favorite part of the Angular Signals 😎 It creates a readonly signal, which value will be recalculated every time its value is requested (not on every change of the signals). If signals inside the function were not modified, then the previous result of the computation will be returned.
The most amazing feature of computed()
— it doesn’t allow modifications of signals in its scope. Of course, there are ways to turn this protection off, but at least this function encourages you to create a declarative reactive system.
computed()
expects that computation will be lightweight and quick, and will cause no side-effects. Don’t modify any variables here — not only signals but any variables outside of the function. Don’t modify the DOM, don’t toggle flags, don’t record values, and don’t run asynchronous tasks.
Quite often you will use the result of one computed()
in a few other computed()
— this reason alone is enough to follow all the limitations of computed()
.
effect()
is a weapon of the last resort. It will run its function at least once (but not instantly — read more about it in this article).effect()
will also run it when signals, unwrapped in that function, are modified (with the same memoization as incomputed()
). Can be created only in an injection context (or in any context, if anInjector
is provided).
Inside the effect()
, you are allowed to make dirty things — change the state imperatively, modify the DOM manually, and even run asynchronous code (although it’s the most filthy thing, because effect()
can not track values of the signals, unwrapped in the asynchronous code).
If you need to use effect()
— try to find a way to don’t use it. Only when all attempts are failed, use effect()
. It makes your code imperative, less reliable, and more fragile. It’s too easy to create endless loops here and non-expected side-effects — by default, writing to signals inside effect()
is not allowed, but:
It can be turned off easily, and most of the users will just add
{allowSignalWrites: true}
to quickly remove an error.Side effects in an asynchronous code will not be tracked.
setTimeout()
, again, to your service ;)
Because this protection is so easy to break unintentionally (somewhere deep in the stack of the called functions), effect()
should only be used when there is no other way.
In the Angular source code, effect()
is not even part of the Signals — it’s located in the renderer3 folder. Because Angular has to modify the DOM — it’s the framework’s responsibility.
Your code should modify the DOM by updating the bindings in the templates — Angular will listen to their changes and then will modify the DOM. That’s the idiomatic way.
toSignal()
converts an Observable into a Signal. Requires an injection context or anInjector
, and instantly subscribes to the given observable. Subscription is quite an important thing for observables, so you should be sure that subscription will not cause undesired side effects and will be actually used (without subscribing “just in case”).
This function might create an illusion that you can just wrap your observables with toSignal()
and forget about the difference. But the truth remains the same: Signals should always be able to return their current value, while Observables might produce it asynchronously. It’s easier to update some signal in the observable itself — this way observable remains an Observable, signal remains a Signal.
toObservable()
creates a Signal from an Observable. Useseffect()
under the hood, therefore requires an injection context or anInjector
.
This function is more useful — Signals can be read at any moment without causing any side effects, so you can convert some of your signals to use them in your observables. Of course, you can just read the signals in the code of your observables, but when you need to react to the changes of a Signal, it’s quite handy to use an observable, created from a signal.
In Angular v17 we’ll get input()
and model()
.
📐 Creating new Components using Signals
The best way to create a new component is to start from the template. Create a template, declare what bindings you need, and start implementing them as signals. Some of them will be computed()
signals (you’ll see how handy is computed()
for the templates).
If your thinking process will go this way, you’ll create a declarative system. Usage of computed()
will encourage you to avoid imperative pitfalls.
You’ll also notice how easy it is to use signals in the templates, that signals are very good for simple and synchronous reactivity, and that observables are still needed for non-trivial asynchronous tasks, and any tasks involving network requests.
Here I’ll answer some questions, frequently asked in streams and podcasts:
🌶️ Will Signals replace NgRx?
As a big fan of NgRx, I can honestly say that for very simple components you can, indeed, replace ComponentStore with a class with signals. You could do it with the famous “class with a BehaviorSubject” approach, but there were very good reasons to use ComponentStore instead — select()
, effect()
, patchState()
. But now computed()
can replace selectors, signal.set
can replace patchState()
, signal.update
can replace store updaters, and all you’ll need is a replacement for effect()
. An example of implementation you can find in this article.
And if you are used to working with NgRx Store, with actions, then Signals simply can’t replace it out of the box. You could, of course, create some mini-library, but it’s much more reliable to simply keep using NgRx Store (you can even use signal-based stores now).
🌶️ Yay, no more RxJS!
As explained above, observables will remain in your code. You might not need async
pipe anymore, and you might move most of your code to Signals, but there still will be cases where observables are more suitable. Even in simple and small applications:
XHR requests;
Routing;
Output() bindings;
Form controllers events;
DOM events.
Also, it doesn’t mean that you now don’t need to know a thing about observables, and just subscribe()
is all you need — you’ll need observables not only for simple XHR requests but also for more complicated things. And asynchronous code just can not be simple. It’s better to read some articles, watch some courses, and resolve this issue once and forever.
Beginners will (since v17) start with signals first, and their learning curve is significantly less steep. They will meet the observables, just a little bit later, when their code will start serving real-life needs.
😰 Should we start rewriting our app?
No, Signals are completely opt-in. Your code will keep working for the “foreseeable future” (as the Angular team said). There are thousands of Angular apps in Google — they simply can’t rewrite them all, so the Angular team has no plans to deprecate currently existing components.
With or without signals, the biggest benefit you can get right now is the refactoring itself: during the refactoring, you’ll overhaul the code and fix some issues here and there — it will bring the biggest value.
If you already use OnPush
strategy and async
pipe, you’ll get no performance benefits from switching to signals.
If you use Default
change detection strategy, then you have a very good reason to start refactoring and practicing signals.
🧐 If we will use only signal-based components in our new application, what are the benefits?
Theoretically, you can get rid of ZoneJS, and the performance of your application will be better than OnPush
+ async
. In practice, all the libraries you use should also use signal-based templates only. But it will happen someday, so it’s better to prepare your app and start using only signals in the templates of the new components/apps (and it’s completely fine to keep using observables outside of the templates — components, services, stores).
Granular reactivity is another benefit — only part of your template will be checked and updated. The current plan is to do it per view. Here you can read what is “View” in a component.
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.