Angular 16: Signals Take Center Stage

Ever since Angular 14 shipped, every subsequent release has packed in a substantial set of additions, and Angular 16 is certainly no outlier in that regard.

The Signals pattern has been a hot topic in the community for some time, yet Angular 16 introduces a lot more than just this single concept.

So what exactly are Signals, and how do you put them to use?

All the answers are right here.

Signals

The Signal pattern has existed since the early days of the Solid JS library and operates on a push/pull model.

With pull, you fetch the current value from a signal, while push lets you assign a new value or update the existing one.

Regardless of the operation, a signal always holds a value, and reading it is always a synchronous operation.

Angular 16 is introducing this pattern-based API, aiming to improve the efficiency of change detection throughout the framework.

What is the motivation behind this?

Upon application startup, Angular takes over several low-level browser APIs, such as the addEventListner function.

This interception is powered by the Zone JS library. Zone is a dependency that Angular relies on internally to detect changes by observing:

  • DOM interactions such as clicks and mouseover
  • HTTP requests
  • setTimeout and setInterval calls

Whenever an event occurs, Angular triggers change detection and re-renders the page.

Like most client-side rendered applications, Angular structures your app as a component tree.

In Angular, every component is assigned its own change detector. Consequently, a modification in one child component causes the entire tree to be checked, without considering each component's specific dependencies — a strategy known as dirty checking.

Even with OnPush strategy enabled, the change detection cycle still traverses the whole tree. The difference is that, unlike the default mode, components using OnPush that lack updated dependencies are simply not re-evaluated.

This makes it evident that Angular's change detection isn't optimal. Integrating Signal is meant to address this inefficiency.
Signals enable change detection at a granular, signal-by-signal level. A component's change detection is triggered only when a relevant signal updates, eliminating the need to walk the entire tree or depend on Zone JS. In time, Zone JS may become optional.

Creating a signal is straightforward — invoke the signal function with an initial value.

const counter = signal(0) // create a signal with initial value to 0;

console.log(this.counter()) // display 0
Enter fullscreen mode Exit fullscreen mode

The result is a WritableSignal, granting you the ability to set a fresh value or mutate the existing one.

/**
Set function let to set a signal's value with a new value. Usefull if you need to change the data structure when the new value is not dependent of the old one.

Notify all dependents.
**/
set(value: T): void;

/**
Update function let to update the signal's value if the update depends on the precedent value.
In other words this function help if you want to update the value of the signals in a immutable way.

Notify all dependents.
**/
update(updateFn: (value: T) => T): void;

/**
Mutate function let to update the signal's value by mutating it in place.
In other words this function is useful for making internal change to the signal's value without changing its internal identity.

Notify all dependents.
**/
 mutate(mutatorFn: (value: T) => void): void;

/**
Return a readonly signal.
**/
asReadonly(): Signal<T>;
Enter fullscreen mode Exit fullscreen mode

Computed

Derived signals are created from other signals that they depend on.

const person = signal<{ firstname: string; lastname: string}>({ firstname: 'John', lastname: 'Doe'})

// Automatically updates when person() change;
const presentation = computed(() => ${person().firstname}${person().lastname}`;
Enter fullscreen mode Exit fullscreen mode

A computed signal is only re-evaluated when at least one of its underlying dependencies changes.

Computed can serve as an alternative to the well-known Pipe.

Effect

Effects handle side-effect operations that read from any number of signals, and they are automatically scheduled to execute again whenever those signals change.

The API is structured like this:

function effect(
  effectFn: (onCleanup: (fn: () => void) => void) => void,
  options?: CreateEffectOptions
): EffectRef;
Enter fullscreen mode Exit fullscreen mode

Here is a practical demonstration:

query = signal('');
users = signal([]);
effect(async (onCleanup) => {
  const controller = new AbortController();
  const response = await fetch('/users?query=' + query())
  users.set(await response.json());
  onCleanup(() => controller.abort())
})
Enter fullscreen mode Exit fullscreen mode

Signals function as a fundamental reactive system within Angular, applicable inside components and beyond, such as within services.

Automatic route params mapping

Consider a routing configuration like this:

export const routes: Routes = [
 { path: 'search:/id',
   component: SearchComponent,
   resolve: { searchDetails: searchResolverFn }
 }
]
Enter fullscreen mode Exit fullscreen mode

Prior to Angular 16, the ActivatedRoute service had to be injected to access URL parameters, query parameters, and route data.

@Component({...})
export class SearchComponent {
  readonly #activateRoute = inject(ActivatedRoute);
  readonly id$ = this.#activatedRoute.paramMap(map(params => params.get('id');
  readonly data$ = this.#activatedRoute.data.(map(({ searchDetails }) => searchDetails)
}
Enter fullscreen mode Exit fullscreen mode

In Angular 16, injecting ActivatedRoute for these parameters is no longer required, as route parameters can be directly bound to component inputs.

To use this with a module-based app, you enable the option within the RouterModule configuration.

RouterModule.forRoot(routes, { bindComponentInputs: true })
Enter fullscreen mode Exit fullscreen mode

For standalone applications, a dedicated function must be invoked.

provideRoutes(routes, withComponentInputBinding());
Enter fullscreen mode Exit fullscreen mode

Once activated, the component becomes much more concise.

@Component({...})
export class SearchComponent {
  @Input() id!: string;
  @Input() searchDetails!: SearchDetails
}
Enter fullscreen mode Exit fullscreen mode

Required Input

A long-awaited community feature was the ability to enforce mandatory inputs.

Previously, developers relied on various workarounds:

  • Throwing an error in the NgOnInit lifecycle hook if the input wasn't provided
  • Incorporating the required inputs directly into the component's selector

Both approaches had their pros and cons.

Starting with version 16, you can mark an input as required simply by passing a configuration object to the input's decorator metadata.

@Input({ required: true }) name!: string;
Enter fullscreen mode Exit fullscreen mode

New DestroyRef injector

Angular v16 introduces a new provider known as DestroyRef, enabling you to register cleanup callbacks for a given lifecycle scope. This applies to components, directives, pipes, embedded views, and EnvironmentInjector instances.

Its usage is straightforward.

@Component({...})
export class AppComponent {
  constructor() {
    inject(DestroyRef).onDestroy(() => {
      // Writte your cleanup logic
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

This provider allows Angular to consolidate common cleanup tasks, like unsubscribing from observables.

export function destroyed() {
  const replaySubject = new replaySubject(1);

  inject(DestroyRef).onDestroy(() => {
    replaySubject.next(true);
    replaySubject.complete();
  });

  return <T>() => takeUntil<T>(replaySubject.asObservable());
}
Enter fullscreen mode Exit fullscreen mode
@Component({...})
export class AppComponent {
  readonly #untilDestroyed = untilDestroyed();

  ngOnInit() {
    interval(1000)
      .pipe(this.#untilDestroyed())
      .subscribe(console.log);
  }
}
Enter fullscreen mode Exit fullscreen mode

Vite as Dev Server

Angular 14 brought the option to leverage EsBuild, a new JavaScript bundler.

EsBuild was lauded for its speed, capable of cutting build times by around 40%. However, this enhancement was confined to production builds — it couldn't be used for development (dev server).

With the upcoming Angular release, Esbuild will also power the development experience, courtesy of Vite.

To enable this, modify the builder in the angular.json file as follows:

"architect": {
 "build": {
  "builder": "@angular-devkit/build-angular:browser-esbuild",
     "options": { ... }

Enter fullscreen mode Exit fullscreen mode

Note: this capability is still in an experimental stage.

Non Destructive hydration

Angular enables server-side rendering through Angular Universal.

However, applications could often end up inefficient, largely due to hydration.
Previously, hydration was destructive — the entire page was discarded and rebuilt from scratch once the browser fetched and executed the JavaScript.

The good news is that the APIs have been redesigned to facilitate partial hydration. Now, once the HTML is loaded and the DOM is built, the entire structure is traversed to attach event listeners and rebuild the app's state for reactivity, skipping the need for a full re-render.

Conclusion

Angular 16 unquestionably brings compelling new features, some still experimental, such as Signals and the Vite dev server.

These features will undoubtedly reshape how we build Angular applications, reducing boilerplate, boosting optimization, and paving the way for simpler integration of technologies like Vitest or Playwright.

Since Angular 16 hasn't been officially released, some APIs described here may evolve. Still, this gives you a solid preview of what to expect from the next release.