Angular signals in a zoneless application

An Angular signal is a container that holds a value that can change over time, and when that value changes, it notifies all its consumers.

Signals efficiently track the application state, enabling applications to optimise change detection, UI rendering updates and performance. Signals can hold all types of data, including primitives, arrays and objects. Signals can be either writable or read-only.

See my demo application of signals in a zoneless framework at GitHub.

Writable signals

Writable signals are created and initialised by calling the signal function with the signal's initial value passed as an argument:

basket.service.ts

import { Injectable, signal } from '@angular/core';
import { BasketItem } from '../interfaces/index';

@Injectable({ providedIn: 'root' })
export class BasketService {
  basketItems = signal<BasketItem[]>([]);
}

Computed signals

Computed signals are read-only signals that derive their value from the value of other signals, they are created using the computed method. The value of a computed signal is recalculated and updated whenever the value of any of its source signals is updated:

basket-item.component.ts

@Component({
  selector: 'app-basket-item',
  standalone: true
})
export class BasketItemComponent {
  totalProductPrice = computed(() =>
    this.item().product.price * this.item().quantity
  );
}

How to read the value of a signal

The value of a signal is read by calling its getter function, that is, by calling the signal's variable, followed by a pair of parentheses:

products.component.html

<ul>
  @for (product of productList(); track product.id) {
    <li>
      <span>{{ product.name }}</span>
      <span class="product-desc">{{product.description}}</span>
      <span class="product-price">£{{ product.price | number : "1.2-2" }}</span>
    </li>
  }
</ul>

How to change the value of a signal

The value of a writeable signal can be updated with the set or update methods. Note that computed signals are read-only signals and therefore their values cannot be updated directly with the set and update methods.

The set signal method

The set method is used to directly change the value of a writeable signal, the new value has to be of the same type as the initial value:

products.component.ts

@Component({
  selector: 'app-products',
  standalone: true
})
export class ProductsComponent implements OnInit {
  getProductList() {
    this.productList.set(products);
  }
}

The update signal method

A new value can be computed from the current value of a signal using the update method, it takes a function with the current value of the signal as an argument, then computes and returns the new value of the signal:

basket.service.ts

@Injectable({ providedIn: 'root' })
export class BasketService {
  basketItems = signal<BasketItem[]>([]);
  addItem(product: Product) {
    this.basketItems.update(items => [...items, { product, quantity: 1 }]);
  }
}

Signal inputs

Signal inputs, as the name implies, are signals. They are a reactive alternative to input properties that are declared with the @Input decorator and are used to pass reactive data from parent components to child components. Signal inputs are read-only signals:

@Component({
  selector: 'app-quantity-selector',
  standalone: true
})

export class QuantitySelectorComponent {
  linkToBasket = input<string>();
}

Required signal inputs

Assigning an initial value to a signal input is optional by default, however it can be marked as required by calling the required property:

basket-item.component.ts

@Component({
  selector: 'app-basket-item',
  standalone: true
})
export class BasketItemComponent {
  item = input.required<BasketItem>();
}

If a signal input is declared as required, an initial value has to be passed to it, by the parent component in the template:

basket.component.html

<div class="basket-content">
  <ul>
    @for (product of basketItems(); track product.product.id) {
      <li>
        <app-basket-item [item]="product"></app-basket-item>
      </li>
    }
  </ul>
</div>

Effects

An effect is a function that references one or more signals, runs at least once to determine the values of the referenced signals, then subsequently every time the value of any of the signals changes. An effect is called with the effect function:

navigation.component.ts

@Component({
  selector: 'app-navigation',
  standalone: true
})
export class NavigationComponent {
  constructor() {
    effect(() => {
      const totalItems = this.basketService.totalItemsCount();
      this.messageService.setMessage(`You have ${totalItems} in your basket`);
    });
  }
}

Zoneless change detection

Angular uses the ZoneJS library to manage change detection in applications, it works by intercepting API calls and event listeners with the functionality that triggers the change detection process. ZoneJS comes at a cost to performance and developer experience.

Angular is gradually introducing a new reactivity model which will eventually eliminate the need for ZoneJS. Signals, computed and effects are part of this model. Experimental zoneless change detection which allows applications to run without ZoneJS is now available.

How to enable zoneless change detection

The following are the two steps required to enable zoneless change detection in an Angular application:

  • In the Angular configuration file - angular.json, remove zone.js from all polyfills arrays.
  • In the application's bootstrap configuration file - app.config.ts, remove the default change detection provider - provideZoneChangeDetection() from the providers array, then import and add the experimental change detection provider - provideExperimentalZonelessChangeDetection()):

app.config.ts

import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [provideExperimentalZonelessChangeDetection()]
};