What's New in Angular 16: Signals, Required Inputs and takeUntilDestroyed
Signals, computed and effect, the toSignal/toObservable bridge, required inputs and transforms, and subscription cleanup with takeUntilDestroyed in Angular 16, compared with Angular 12.
Version note: This post covers what Angular 16 introduced; the code targets Angular 16.
Why Angular 16 Is a Turning Point
The versions we have looked at so far rearranged the rooms of the same house. Standalone made NgModule optional, and guards and interceptors turned into plain functions. Angular 16, released on May 3, 2023, goes after the plumbing. It offers a new answer to the question of when and how Angular updates the screen. That answer is called a signal.
In this post we will look at the three building blocks of signals, how they talk to RxJS, two small but very handy input options, the new way to clean up subscriptions, and the router binding parameters straight into inputs. At the end there is a short note on moving from 15 to 16.
How We Held State in Angular 12
Remember the change detection model we covered earlier. You keep a plain property on the component, Zone.js catches a click, a timer or an HTTP response, and Angular walks the component tree from top to bottom to see what changed:
@Component({
selector: 'app-cart-badge',
template: `
<span>{{ count }} items · {{ count * 120 }} USD</span>
<button (click)="add()">Add</button>
`
})
export class CartBadgeComponent {
count = 0;
add(): void {
this.count++;
}
}
It works, but Angular has no idea what actually changed. count is just a number; Angular doesn't know who reads it or who is affected when it changes. So it has to check everywhere it might matter. Think of a building manager who hears there is a leak somewhere and knocks on every single door.
signal, computed and effect
In Angular 16 the same component can look like this:
import { Component, computed, effect, signal } from '@angular/core';
@Component({
selector: 'app-cart-badge',
standalone: true,
template: `
<span>{{ count() }} items · {{ total() }} USD</span>
<button (click)="add()">Add</button>
`
})
export class CartBadgeComponent {
count = signal(0);
total = computed(() => this.count() * 120);
constructor() {
effect(() => console.log('Items in cart:', this.count()));
}
add(): void {
this.count.update(c => c + 1);
}
}
signal(0) creates a box with an initial value of 0. To look inside, you call it like a function: count(). There are two ways to change the value. set(5) writes a new value directly, while update(c => c + 1) computes the new value from the old one.
computed is a read-only signal derived from other signals. It tracks which signals it reads on its own. As long as count doesn't change, it doesn't recompute; when it does change, the next read recalculates. It's a formula cell in a spreadsheet: change A1 and B1 updates by itself, without you retyping the formula.
effect is a side effect that re-runs whenever one of the signals it reads changes. It's a good fit for logging or writing to localStorage. There is one rule: it has to be created in an injection context. That's why we put it in the constructor, the same rule as the inject() function we saw earlier.
The real difference is in the template. When the template reads count(), Angular records that dependency. The building manager now knows which apartment has the leak. In Angular 16 change detection is still triggered by Zone.js, so signals don't remove the existing mechanism. But this dependency information is exactly what an Angular without Zone.js would be built on, and we will dig into that in upcoming posts.
Status note: in Angular 16, signal, computed, effect and the RxJS bridge we are about to see are in developer preview. You can use them, but the API may change in later versions. If you ship them to production, do it knowingly.
A Bridge to RxJS: toSignal and toObservable
Signals don't mean RxJS gets thrown away. HTTP requests, event streams and search boxes that need a debounce are still observable territory. Angular 16 ships two functions that connect the two worlds in the @angular/core/rxjs-interop package:
import { Component, inject, signal } from '@angular/core';
import { NgFor } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs';
interface Product { id: number; name: string; }
@Component({
selector: 'app-product-search',
standalone: true,
imports: [NgFor],
template: `
<input (input)="query.set($any($event.target).value)" />
<p *ngFor="let p of results()">{{ p.name }}</p>
`
})
export class ProductSearchComponent {
private http = inject(HttpClient);
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap(q => this.http.get<Product[]>(`/api/products?q=${q}`))
),
{ initialValue: [] }
);
}
toObservable(this.query) produces an observable that emits every time the signal changes. We attach the operators we already know: wait 300 milliseconds, then fire the request, and cancel the old one if a new keystroke arrives. toSignal turns the result back into a signal, which the template reads as results().
We pass initialValue because an observable may not emit right away. Without it, the signal's type becomes Product[] | undefined and the template has to check for undefined every time. The nice part is that toSignal opens the subscription itself and closes it when the component is destroyed. It's like moving the job of the async pipe we saw earlier into the class. Both functions must be called in an injection context; a field initializer qualifies.
Two New Input Options: required and transform
In Angular 12 we wrote a mandatory input and a boolean flag like this:
export class UserCardComponent implements OnInit {
@Input() userId!: number;
@Input() compact = false;
ngOnInit(): void {
if (this.userId == null) {
throw new Error('userId is required');
}
}
}
There are two pain points. If you forget to pass userId, you only find out at runtime, in the console. And when you write <app-user-card compact>, compact receives an empty string, not true; with strict template checks on, that's a type error anyway. With Angular 16:
import { Component, Input, booleanAttribute, numberAttribute } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `<p>User #{{ userId }}</p>`
})
export class UserCardComponent {
@Input({ required: true }) userId!: number;
@Input({ transform: booleanAttribute }) compact = false;
@Input({ transform: numberAttribute }) maxItems = 5;
}
required: true arrived in 16.0. If the parent doesn't bind userId, you now get a compile error, so the mistake reaches you instead of your users. transform arrived in 16.1 and runs the incoming value through a function before it lands in the property. The built-in booleanAttribute and numberAttribute cover the two most common needs:
<app-user-card [userId]="42" compact maxItems="10"></app-user-card>
Here compact really is true, and maxItems is the number 10, not a string. You can write your own transform as well; it just takes the raw value and returns the converted one.
Cleaning Up Subscriptions: DestroyRef and takeUntilDestroyed
Remember the memory leak topic we covered earlier. In Angular 12, closing a subscription safely took this ritual:
export class ClockComponent implements OnInit, OnDestroy {
now = new Date();
private destroy$ = new Subject<void>();
ngOnInit(): void {
interval(1000)
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.now = new Date());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
A Subject, two interfaces, two lifecycle hooks. The same pattern in every component. In Angular 16:
import { Component } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-clock',
standalone: true,
template: `{{ now.toLocaleTimeString() }}`
})
export class ClockComponent {
now = new Date();
constructor() {
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe(() => this.now = new Date());
}
}
takeUntilDestroyed() figures out the lifetime of the place it's called from and completes the stream when the component is destroyed. The no-argument version needs an injection context: a constructor or a field initializer. If you subscribe somewhere else, like ngOnInit, you pass in the piece behind it explicitly:
export class ClockComponent implements OnInit {
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => console.log('tick'));
const id = setInterval(() => console.log('tock'), 5000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}
DestroyRef is the general way of saying "when I go away, do this too". With onDestroy you can register cleanup that has nothing to do with RxJS. It also lets a helper function tie itself to the lifetime of whichever component calls it. Since takeUntilDestroyed lives in the rxjs-interop package, it is in developer preview in this version too.
Route Parameters Straight into Inputs
In the routing post we covered earlier, a product detail page read its parameter like this:
export class ProductDetailComponent implements OnInit {
productId: string | null = null;
constructor(private route: ActivatedRoute) { }
ngOnInit(): void {
this.route.paramMap.subscribe(params => {
this.productId = params.get('id');
});
}
}
In Angular 16 you can hand that job to the router by adding a single feature to the provideRouter we saw earlier:
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideRouter(
[{ path: 'products/:id', component: ProductDetailComponent }],
withComponentInputBinding()
)
]
});
// product-detail.component.ts
export class ProductDetailComponent {
@Input() id!: string;
}
With withComponentInputBinding() on, the router writes route parameters, query parameters and route data into inputs with matching names. The component no longer needs to know about ActivatedRoute, and in tests you set the input directly instead of faking a route. If the user moves to another product while the same component stays on screen, the input gets updated; to react to that, use ngOnChanges or a setter.
What Changed in the Build and Behind the Scenes
With CLI 16 the esbuild-based builder moved to developer preview. To try it, change the builder line of the build target in angular.json:
"builder": "@angular-devkit/build-angular:browser-esbuild"
Builds get noticeably faster than with webpack, though some options may not be supported yet. Think of it as a preview of the new build system that becomes the default in the next step. There are other CLI notes too: ng new --standalone now creates a project without NgModule, though it's still opt-in, and ng generate guard and resolver produce the functional versions by default.
The most important breaking change is the removal of ngcc. If you still depend on a library published in the old View Engine format, it won't work in Angular 16. On the SSR side, non-destructive hydration arrived in developer preview with provideClientHydration(); we will touch on server-side rendering briefly in upcoming posts.
Moving to Angular 16: ng update
As we saw earlier, ng update moves one major version at a time. A project on 15 is upgraded like this:
ng update @angular/core@16 @angular/cli@16
Check your environment before you start. Angular 16 wants Node.js ^16.14.0 or ^18.10.0; Node 14 support is gone. TypeScript needs to be somewhere between 4.9.3 and 5.1, and zone.js moves up to ~0.13.0. Also take a look at your dependencies: if a package is stuck in the View Engine format, find a newer release or an alternative before you upgrade. Switching to signals is not required; your existing code keeps working as it is.
A Small Experiment
Build a small temperature converter. Define celsius = signal(20), compute fahrenheit with computed, and add two buttons that raise and lower the temperature by one degree. Add an effect in the constructor that logs every change. Then open a subscription with interval(5000) that bumps the temperature every five seconds and tie it to takeUntilDestroyed(). Remove the component from the screen with an *ngIf and watch the logs stop; then delete takeUntilDestroyed() and repeat the experiment.
In this experiment we still wrote the template with *ngIf and *ngFor. In the next post we look at the new control flow syntax that came with Angular 17, at views that load piece by piece with @defer, and at the new application builder that became the default.