What’s new in Angular 16? – Signals, Hydration, esbuild and More!

Angular 16 comes with two big innovations: signals and non-destructive hydration. Numerous further improvements simplify working with the framework.

Angular 16 comes with two big innovations: signals and non-destructive hydration. There are also numerous improvements that simplify working with the framework. In this article, I will go into the most important innovations using examples. The source code can be found here in the ng16-features branch.

Signals

Probably the biggest innovation in Angular 16 are signals. This is a simple reactive building block that will enable fine-grained change detection without Zone.js in the future. A signal is an object that takes a value. Consumers can read and update this value. They can also be informed when the value changes:

Schematic Representation of Signals

If a component's template binds to a signal, Angular triggers change detection as soon as the signal changes. An example of this can be found in the next listing, where all the properties to be bound from, to, and flights are available as signals:

import { computed, effect, signal } from '@angular/core';
[…]

@Component([…])
export class FlightSearchComponent implements OnInit {

  from = signal('Hamburg'); // in Germany
  to = signal('Graz'); // in Austria
  flights = signal<Flight[]>([]);

  flightRoute = computed(() => this.from() + ' to ' + this.to());

  constructor() {
    effect(() => {
      this.search();
    });
  }

  async search() {
    if (!this.from() || !this.to()) return;

    const flights = await this.flightService.findPromise(
      this.from(),
      this.to()
    );
    this.flights.set(flights);
  }

}

The signal function, which the Angular team placed in the @angular/core package, takes care of generating the signals . This function returns a so-called WritableSignal<T>, i.e. a signal that the program code can not only read but also update. T represents the managed value type. In most cases, signal can infer this type from the default value passed. If not, a type parameter must be passed to signal as with the flight property.

The search method reads and updates these signals. In addition, getters and setters are used. To call the getter, the signal is treated like a function. The signal offers the setter in the form of a set method.

This listing shown also creates a computed signal using the computed function. The signal updates the calculation passed as a lambda expression when one of the signals used changes. The return value from computed is of type Signal<T>. Unlike the previously mentioned WritableSignal<T>, this one is read-only.

The constructor uses the effect function to define a side effect. Whenever one of the signals used in it changes, the transferred lambda expression is executed again. Signals used in invoked methods like search are honored by effect in the same way.

The template of the component binds itself to the signals:

<input [ngModel]="from()" (ngModelChange)="from.set($event)" name="from">
<input [ngModel]="to()" (ngModelChange)="to.set($event)" name="to">

<b>{{ flightRoute() }}</b>

<div class="row">
  <div *ngFor="let f of flights()">
    <app-flight-card [item]="f" [(selected)]="basket()[f.id]" />
  </div>
</div>

Changes to signals cause Angular to trigger change detection and refresh the browser view. This behavior is similar to that of observables that are bound to the template with the async pipe. For this reason, the OnPush change detection strategy can also be activated to improve performance when using signals.

However, future Angular versions will go one step further and offer fine-grained change detection. This should allow parts of templates to be updated instead of whole templates.

Unlike Observables, Signals does not require consumers to unsubscribe. If the code of a component or a template consumes a signal, the unregistration takes place automatically when the component is destroyed. The same applies to use within other Angular-based building blocks such as services or directives -- the consumer is bound to the lifetime of the respective building block.

In version 16, signals are initially available as a developer preview. This means that they are subject to change in future versions. As with standalone components, signals will also interact with existing code. So there is no need to rush: existing code does not necessarily have to be migrated.

RxJS-Interop

Signals and RxJS have some similarities, especially since both allow reactive applications. In contrast to RxJS, however, Signals were deliberately kept very simple. The primary goal is to support change detection. The RxJS interop layer delivered with Angular from version 16 makes it possible to combine the simplicity of signals with the many and sometimes complex possibilities of RxJS. The interop layer is in the code>@angular/core/rxjs-interop namespace and provides functions for converting signals into observables and vice versa:

import { toObservable, toSignal } from '@angular/core/rxjs-interop';
[…]

from = signal('Hamburg');
to = signal('Graz');

from$ = toObservable(this.from);
to$ = toObservable(this.to);

flights$ = combineLatest({ from: this.from$, to: this.to$ }).pipe(
  debounceTime(300),
  tap(() => this.loading.set(true)),
  switchMap((combi) => this.flightService.find(combi.from, combi.to)),
  tap(() => this.loading.set(false))
);

flights = toSignal(this.flights$, { initialValue: [] });

The toObservable function converts the signals from and to into corresponding observables. This enables the use of RxJS-based operators such as combineLatest , debouceTime and switchMap. The result is an observable that returns arrays of Flight objects. The toSignal function creates a signal based on it.

Since a signal, in contrast to an observable, always has a value, the example defines an initial value. Alternatively, the caller of the toSignal function could assert that the observable publishes an initial value synchronously (e. g. using startsWith). To do this, set the requireSync property to true:

flights = toSignal(this.flights$, { requireSync: true });

In both cases, toSignal derives the typing of the signal from the observable. In the case shown, a signal<Flight[]> results. If neither initialValue nor requireSync is used, toSignal includes the type undefined in the typing. Thus, a Signal<Flight[] | undefined> that has undefined as its initial value.

Signals based on observables are also tied to the lifetime of the building block using them – for example, the respective component or service. However, this is not the case with observables based on signals. The Angular team didn't want to change the usual RxJS behavior. If you still want to bind a subscription to an observable to the lifetime of the respective building block, you can access the takeUntilDestroyed operator of the interop-layer:

interval(1000)
  .pipe(takeUntilDestroyed())
  .subscribe((counter) => console.log(counter));

Non-destructive Hydration

Single page applications (SPAs) enable good runtime performance. However, the initial page load usually takes a few seconds longer than with classic web applications. This is because the browser has to load large amounts of JavaScript code in addition to the actual HTML page before it can render the page. The so-called First Meaningful Paint (FMP) only takes place after a few seconds:

Client-side rendering of a SPA

While these few seconds are hardly an issue in business applications, they actually pose a problem for public web solutions such as web shops. Here it is important to keep the bounce rate low and this can be achieved, among other things, by keeping waiting times as short as possible.

It is therefore common to render SPAs for such scenarios on the server side so that the server can already deliver a finished HTML page. The caller is thus quickly presented with a page. Once the JavaScript bundles have loaded, the page is also interactive. The next image illustrates this: The First Meaningful Paint (FMP) now takes place earlier. However, the site will only become interactive later (Time to Interactive, TTI).

Server-side rendering of an SPA

To support solutions where the initial page load matters, Angular has offered server-side rendering (SSR) since its early days. However, the behavior of this SSR implementation has been "destructive" in the past. This means that the loaded JavaScript code re-rendered the entire page. All server-side rendered markup was replaced with client-side rendered markup. Unfortunately, this is also accompanied by a slight delay and flickering. Metrics show that this degrades startup performance.

Angular 16 also addresses this issue by reusing the already server-side rendered markup from the JavaScript bundles loaded into the browser. We are talking about non-destructive hydration here. The word hydration describes the process that makes a loaded page interactive using JavaScript.

To use this new feature, first install the code>@nguniversal/express-engine package for SSR support:

ng add @nguniversal/express-engine

After that, non-destructive hydration is enabled with the standalone API provideClientHydration:

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
  ]
};

The listing sown takes care of this in the app.config.ts file . The structure of the ApplicationConfig type published there is used in the main.ts file when bootstrapping the application. Incidentally, the app.config.ts file is set up by the CLI when a new application is set up with the --standalone switch.

To debug an application that relies on SSR or hydration, the using schematics set up the npm script ssr:dev:

npm run ssr:dev

Behind it is a development server that was developed by an extremely charming Austrian collaborator and runs the application in debug mode on both the server and client side.

More Details on Hydration in Angular

If the SPA calls Web APIs via HTTP during server-side rendering, the responses received are also automatically sent to the browser via a JSON fragment within the rendered page. When hydrating, the HttpClient in the browser uses this fragment instead of making the same request again. With this, Angular speeds up hydration. If this behavior is not desired, it can be deactivated with the withNoHttpTransferCache function:

provideClientHydration(
    withNoHttpTransferCache()
),

For non-destructive hydration to work, the markup rendered on the server side must match the markup on the client side. This cannot always be guaranteed, especially with third-party components or when using libraries that manipulate the DOM directly. In this case, however, non-destructive hydration can be deactivated for individual components with the ngSkipHydration attribute:

<app-flight-card 
      ngSkipHydration 
      [item]="f" 
      [(selected)]="basket()[f.id]" />

Angular does not allow data binding for this attribute. Also, Angular expects ngSkipHydration to be either zero or true. If you want to generally exclude hydration for a component, you can also set this attribute via a host binding:

@Component({
  […]
  host: { 'ngSkipHydration': 'true' }
})

If several Angular applications run in parallel on one side, Angular must be able to distinguish between these applications using an ID. The token APP_ID is used for this:

{ provide: APP_ID, useValue: 'myApp' },

The result of the new non-destructive hydration is quite impressive. The following two figures show some Lighthouse metrics for the example application used here. The former refers to classic SSR and the latter to the new non-destructive hydration.

Classic SSR:

Classic SSR

SSR with Non-Destructive Hydration

SSR with Non-Destructive Hydration

Apart from creating a production build and enabling HTTP compression in the node-based web server responsible for server-side rendering, no optimizations have been implemented.

Like Signals, non-destructive hydration will only be released with Angular 16 as a developer preview. Based on this, the Angular team would like to research regarding further hydration variants. Discussions include progressive hydration and partial hydration:

Progressive Hydration:

Progressive hydration

Partial Hydration:

Partial Hydration

With progressive hydration, the browser downloads several small bundles instead of one large bundle. This means that the currently required application parts can become interactive more quickly. With partial hydration, the application tries to exclude specific bundles from the download. Code for static page areas as well as code for components that are not scrolled into the visible area can thus remain outside.

Inputs as Mandatory Fields

A rather small, but nevertheless important innovation are mandatory inputs for components. These are properties that must be specified when integrating components. This saves the component from checking against undefined but also from expanding the type used to undefined. To mark an input as mandatory, the new required flag must be set to true:

@Component({ … })
export class FlightCardComponent {
  @Input({ required: true }) item: Flight = initFlight;
     […]
}

Router

The router has also received a few nice roundings. For example, it can now be instructed to pass routing parameters directly to inputs of the respective component. For example, if a route is called with ;q=Graz, the router assigns the value Graz to the input with the name q:

@Input ( ) q = '' ;

Retrieving the parameter values via the ActivatedRoute service is no longer necessary. This behavior applies to parameters in the data object, in the query string, as well as to the matrix parameters that are usual in Angular. In the event of a conflict, this order also applies, e.g. if present, the value is taken from the data object, otherwise the query string is checked and then the matrix parameters. In order not to disrupt existing code, this option must be explicitly activated. For this, the withComponentInputBinding function is used when calling provideRouter:

provideRouter(
  APP_ROUTES,
  withComponentInputBinding()
),

In addition, the router now has a lastSuccessfulNavigation property that provides information about the current route:

router = inject(Router);
[…]
console.log(
  'lastSuccessfullNavigation',
  this.router.lastSuccessfulNavigation
);

DestroyRef

As mentioned above, Angular ties the lifetime of Signal consumers to that of the specific Angular building block, e.g. the current component. This is made possible by the new DestroyRef -- a service that informs you when the current building block is about to be destroyed:

destroyRef = inject(DestroyRef);
[…]

const sub = interval(1000)
        .subscribe((counter) => console.log(counter));

const cleanup = this.destroyRef.onDestroy(() => {
  sub.unsubscribe();
});

// cleanup();

The onDestroy method accepts a callback. The DestroyRef executes such callbacks shortly before the current building block is destroyed. The example shown uses this mechanism to end a subscription. If you change your mind, you can call the resulting cleanup function, as indicated in the comment at the end. In this case, the callback is removed again and is therefore not executed when the building block is destroyed.

Injection Context

Originally intended for internal purposes, the inject function has recently made dependency injection easier. However, since it was extended for broad use by application code, Angular developers are also often confronted with the word injection context . This designates those places in the code where inject may be used: default values for properties in classes, constructors or a factory for a provider. In addition, as shown below, an injection context can be set up with the injector.

Using it in other areas of the application will result in an error:

ngOnInit(): void {
  // Errror: not in InjectionContext!
  const flightService = inject(FlightService);
}

Until now, a function could not check in which context it was called. Because of this, it couldn't send a good error message to the caller. This is exactly what is now be possible with the new assertInjectionContext function:

function selectAllFlights(): Observable<Flight[]> {
  assertInInjectionContext(selectAllFlights);
  const store = inject(Store);
  return store.select(selectFlights);
}

If assertInInjectionContext is not called in an injection context, it throws an error. In order for the caller to identify the error, the error message contains the name of the current function to be passed as a parameter.

In order to create a new injection context if required, Angular from version 16 offers the function runInInjectionContext, which replaces the previously used EnvironmentInjector.runInContext method:

function selectAllFlights2(injector: Injector): Observable<Flight[]> {
  let store: Store | undefined;
  runInInjectionContext(injector, () => {
    store = inject(Store);
  });
  if (store) {
    return store.select(selectFlights);
  }
  return of([]);
}

The runInInjectionContext function needs a reference to an injector and this in turn is via dependency injection -- e.g. B. using inject -- to obtain.

Improvements to the CLI: Standalone, Jest and esbuild

With Angular 16, some schematics were adapted for standalone components. For example, ng new now has a --standalone switch. In addition, both the schematics already used for SSR above and the code>@angular/service-worker package support Standalone APIs.

The CLI team has also invested a lot of time in the new esbuild-based builder as part of Angular 16. The builder is still experimental, but the numbers are already very promising. In the first tests with large applications, the build times could be reduced by a factor of 3 to 4. Also, ng serve now picks up this builder if configured for ng build. If you want to try out the new builder, replace the following line in the angular.json file

"builder" : "@angular-devkit/build-angular:browser" ,

with

"builder" : "@angular-devkit/build-angular:browser-esbuild" ,

Summary

With version 16, the Angular team continues its efforts to make the framework more modern and lightweight at the same time. Signals herald a new era of targeted change detection and with non-destructive hydration Angular takes the first step towards modern hydration scenarios, which are particularly important for public web solutions.

The support for mandatory inputs, the binding of routing parameters and the new DestroyRef also bring convenience to development. In addition, the CLI now allows the creation of applications based on standalone components and the still experimental esbuild-based builder significantly accelerates the building of Angular applications.

More on Modern Angular?

Learn all about Standalone Components in our free eBook:

  • The mental model behind Standalone Components
  • Migration scenarios and compatibility with existing code
  • Standalone Components and the router and lazy loading
  • Standalone Components and Web Components
  • Standalone Components and DI and NGRX

Please find our eBook here:

free ebook

Feel free to download it here now!