Signals
A value plus a list of interested readers. That is the whole mechanism — and it is the one change that makes the rest of Angular 22 easier to understand.
Read this page before the others. Signals are not a library layered on top of Angular; they are how the framework holds state now, and they are the reason zone.js is gone.
What a signal actually is
A signal is a container with two things inside: the current value, and a list of the readers that want to know when the value changes.
That sounds like a BehaviorSubject, and it is — with three differences that matter in practice:
- You read a signal by calling it:
count(). ABehaviorSubjectis read with.valueor.getValue(). - You write with
set()orupdate(). There is no.next(). - Nobody has to remember to unsubscribe. A signal lives and dies with the thing that owns it, and dependencies are discovered automatically.
That last part is the important one. Because Angular can see who read what, it does not have to walk the component tree to find out whether anything changed.
One thing confuses everyone at first: in a template you write {{ count() }}, not {{ count }}. The first calls the signal and gets the value; the second prints the function itself. The compiler error is clear, but it looks wrong for a while.
Reading a signal
In TypeScript you read a signal by calling it:
const count = signal(0);
count(); // 0
count.set(1); // write a value
count.update((value) => value + 1); // compute from the current oneIn a template you do exactly the same thing, and Angular registers the dependency itself. You never say what should update — you just read the value where you need it:
<p>count() = {{ count() }}</p>
<button type="button" (click)="count.update((value) => value + 1)">+1</button>That automatic registration is also why you no longer think about ChangeDetectorRef.detectChanges() or markForCheck(). If a template reads a signal, it is updated when the signal changes — whatever caused the change.
Writing with set() and update()
set() overwrites the value. update() computes from the current one, and it is what you want nine times out of ten, because it does not require you to read the value yourself first:
2022 — BehaviorSubject
2026 — signal
signal + computed + update
A write into a signal updates everything derived from it. Press the buttons.
count() = 0 · double() = 0
Deriving with computed
computed() builds a new signal out of others. It is lazy, memoised, and invalidated only when something it reads changes:
- Lazy: the body does not run until someone reads the value. A
computedthat no template and no code reads never runs at all. - Memoised: read it twice without changing anything in between and the body runs once. In the demo below you can watch the counter of how many times the body actually ran.
- Invalidated on change: change something in the basket and the total is recomputed on the next read — not before.
In 2022 you would typically have written this as a stream pipe in a service layer:
2022 — combineLatest, and the async pipe in the template
2026 — computed in the component
The difference is not just shorter syntax. total$ | async gives you a new subscription per async pipe, a null until the first value arrives, and an error path you have to handle yourself. computed is just a value.
One thing that is easy to get wrong: computing in the template instead of in a computed:
<p>{{ basket().reduce((sum, i) => sum + i.price * i.quantity, 0) }}</p>That is not wrong in the sense of "does not work". It is wrong because the expression is re-evaluated every time the template is checked, and because a template is a poor place to express that kind of logic. Put the computation in a computed and read the result in the template.
computed: lazy, memoised, invalidated
total() and itemCount() are only computed when someone reads them, and only when the basket has actually changed. Press the bottom button to watch neverRead run for the first time.
- Coffee · 42 kr × 2 = 84 kr
- Beans · 89 kr × 1 = 89 kr
itemCount() = 3 · total() = 173
neverRead.body() has run 0 time(s)
Effects are for side effects, not state
effect() runs a function whenever something it reads changes. It also runs once when it is created — otherwise it could not know what its dependencies are.
- It tracks automatically whatever you read inside its body.
- If you write to a signal inside an effect, and the effect does not itself read that signal, wrap the write in
untracked(). Otherwise you can end up with an effect that triggers itself. onCleanuplets you clean up — before the next run, and when the component is destroyed.
effect((onCleanup) => {
const value = this.source();
this.runs.update((count) => count + 1);
untracked(() => this.log.update((entries) => [...entries, `source = ${value}`]));
onCleanup(() => {
// called before the next run, and on destroy
});
});It is tempting to use an effect to keep another signal up to date — as a kind of computed that is allowed to write. Do not. If the value can be computed from what you read, it is a computed. If it cannot, and it must be writable, it is a linkedSignal.
effect: runs on its own, cleans up after itself
The effect runs once on creation and again whenever source() changes. The logging happens inside untracked(), so the effect does not re-run on its own write.
source() = 1 · the effect has run 1 time(s) · self-triggered re-runs: 0
- source = 1
onCleanup called: no
linkedSignal: when derived state must also be writable
linkedSignal() is a signal with a computed starting value that you are still allowed to overwrite. When the source changes, the value is recomputed and the overwrite is discarded.
That is exactly the pattern of a region-by-city picker: the city follows the region until the user picks a different one — and when the region changes, the old pick is no longer valid.
readonly region = signal('Fyn');
readonly cities = () => REGION_CITIES[this.region()];
readonly selected = linkedSignal(() => this.cities()[0]);In 2022 that would have been a field plus an ngOnChanges or a subscribe that reset the field — and a bug source, because the old selection could survive a change it should not have.
linkedSignal: derived, but writable
Pick a city and the linkedSignal holds on to it — until the region changes, when it works the value out again.
region() = Fyn · selected() = Odense
Signals vs RxJS: what changed and what did not
RxJS has not gone anywhere, and HttpClient still returns observables. What changed is where state lives.
- State in signals. Things a template reads and a user changes: a filter, a selected city, a basket. There is no subscription to clean up.
- Streams in RxJS. Things that happen over time: debounce, retry,
switchMap, web sockets, complicated combinations. RxJS is still the right tool there. - A bridge between them.
toSignal()turns an observable into a signal;toObservable()goes the other way. If you wantdebounceTimeon a search box, convert in withtoObservable()and back out withtoSignal().
readonly query = signal('');
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap((query) => this.http.get<Result[]>(`/api/search?q=${query}`)),
),
{ initialValue: [] },
);For the ordinary case — fetch something, show it, handle loading and failure — there is now resource() and httpResource(), which wrap the whole lifecycle in signals. That is the next page.
The un-learn list
Five things from 2022 to stop doing now:
- State in a
BehaviorSubject. It was the only way to do reactive state then. Asignalis the way now. | asyncin the template to display state. A signal is read directly:{{ count() }}.ngOnChangesto derive something from an input. Usecomputed()over the input signal instead — the derivation is then correct from the first render.ChangeDetectorRef.markForCheck()ordetectChanges(). The need disappears once the framework can see what the template read.effect()as a way to keep state in sync. If the value can be computed, it is acomputed; if it must be writable, it is alinkedSignal.
If you remember one thing from this page: read values where you use them, write to them where the change happens, and let the framework work out the rest. The order the rest of the site is built in is on Orientation.