Angular Signal Explained

Signals Basics

Angular Signal was introduced in Angular 17. Angular uses signals established a new way of change detection. It uses the observable design pattern. When one code changes the value of a signal, other code that uses the signal will get notified and we can do something in response to the new value. That’s the main advantage to use signals.

The key concepts in signal:
  1. Writable signals: created by calling the signal() function with a initial value.

  2. Computed signals: computed signals are read-only signals that derive their value from other signals.

  3. Effect: we can use effect() to perform some sort of side effect when the signal value changes without modifying the signal.

  4. Read signal value: need to use yourSignalVarName()

  5. Modify signal: need to use yourSignalVar.set() or yourSignalVar.update() to modify signal value.

In the following example,

  • We create a WritableSignal signal "circleRadius" using signal().

  • We create a computed signal "circleArea" using computed().

  • We display them on the UI using circleRadius() and circleArea().

  • The "Increase Radius" button will increase the value of the signal by calling update() function.

  • The "Reset" button reset the value of the signal to 0 by calling set() function.

  • We use effect() to log the circle radius when the signal value changes.

import { Component, computed, signal, effect } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-signal-demo-1',
  standalone: true,
  imports: [MatButtonModule],
  template: `
    <p>Signal Demo 2</p>
    <!-- display signal value -->
    <p>Circle Radius: {{ circleRadius() }} cm</p>
    <!-- display computed signal value -->
    <p>Circle area: {{ circleArea() }} square cm</p>
    <button mat-flat-button color="primary" (click)="increase()">
      Increase Radius
    </button>
    <button mat-flat-button color="secondary" (click)="reset()">Reset</button>
  `,
  styles: [],
})
export class SignalDemo1Component {
  // create a new signal variable
  circleRadius = signal<number>(0);
  // create a computed signal variable
  circleArea = computed<number>(
    () => Math.PI * Math.pow(this.circleRadius(), 2)
  );

  constructor() {
    effect(() => {
      // use effect to log signal value when it changes
      console.log(`circleRadius changed to ${this.circleRadius()}`);
    });
  }

  increase() {
    // Increase the signal value
    this.circleRadius.update((v) => v + 1);
  }

  reset() {
    // Set signal value to 0
    this.circleRadius.set(0);
  }
}
signal sample 1 running result
Figure 1. Signal sample 1 running result

Signal for Objects or Arrays

Angular needs to trigger the notification when the signal value changes. It has no issue with primitive values. But for object or array value, it won’t consider the value changed if we just mutated the content of the object or array.

In the follow code, we manually mutate the john’s age in function mutate1(). We can see that John’s age changed, but the computed signal dad().age does’t change on the UI. That’s because it bypasses the signal system. The manually change of an object’s property won’t trigger the notification. So, we should always use the Signal API set() or update() to modify signals.

import { CommonModule } from '@angular/common';
import { Component, computed, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';

@Component({
  selector: 'app-signal-demo-2',
  standalone: true,
  imports: [CommonModule, MatButtonModule],
  template: `
    <p>Signal Demo 2</p>
    <!-- display signal value -->
    <div>{{ john().name }}'s age: {{ john().age }}</div>
    <div>{{ dad().name }}'s age: {{ dad().age }}</div>
    <br />
    <div>
      <button mat-flat-button color="primary" (click)="mutate1()">
        Modify John's age manually
      </button>
    </div>
    <br />
    <div>
      <button mat-flat-button color="primary" (click)="mutate2()">
        Modify John's age with update()
      </button>
    </div>
  `,
  styles: [],
})
export class SignalDemo2Component {
  // create a new signal variable
  john = signal({ name: 'John', age: 10 });

  dad = computed(() => ({ name: 'Dad', age: this.john().age + 25 }));

  mutate1() {
    // Increase the signal value, but it won't trigger notification.
    this.john().age++;
  }

  mutate2() {
    // Increase the signal value using update()
    this.john.update((value) => ({ ...value, age: value.age + 1 }));
  }
}

Signal Equation

Angular signal API by default uses "===" to check if the signal value changed. Variables of object value or array hold the reference of memory. The reference won’t change if we change the properties of them. So even we use the set() or update(), the notification won’t happen if we mutate the properties of the object or items of the array. It’s a wrong way to use update() in the following example:

  // This won't work because the reference doesn't change
  this.john.update((value) => {
    value.age += 1;
    return value;
  });

The right solution is to use the spread operator …​ to create a new instance of the value.

this.john.update((value) => ({ ...value, age: value.age + 1 }));

On the other hand, the following code will always trigger the notification even we set the signal with same value.

  // This will trigger the notification ever time it is called.
  this.john.set({name: 'John', age: 10});

The solution for this case is to override the equality method when we create a signal like below:

john = signal(
  {
    name: 'John',
    age: 10
  },
  {
    equal: (a, b) => {
      return a.name === b.name && a.age === b.age;
    }
  }
);

mutateJohnAge() {
  // The notification won't trigger because the signal's equal method will return true
  this.john.set({name: 'John', age: 10});
}

Convert Observable Response to Signal

RxJS Interop is a new API to convert Observable to Signal and vise versa.

  • toSignal: convert Observable to Signal.

  • toObservable: convert Signal to Observable.

Here is the example of using toSignal().

  private http = inject(HttpClient);
  // create a new signal from Observable.
  family = toSignal(this.http.get<Person[]>('http://localhost:3000/family'), {
    initialValue: [],
  });

  john = computed(() => this.family().find((p) => p.name === 'John')!);
  dad = computed(() => this.family().find((p) => p.name === 'Dad')!);

toSignal() creates a read-only Signal. You can’t modify it. This is OK if you display the signal value only. But it is a problem if you want to modify it.

To resolve this problem, we can create a WritableSignal with the ready-only signal value. Here is an example:

  private http = inject(HttpClient);
  // create a new signal from Observable.
  readOnlyFamily = toSignal(this.http.get<Person[]>('http://localhost:3000/family'), {
    initialValue: [],
  });

  // create a signal of signal
  family = computed(() => signal(this.readOnlyFamily()));

  onModifyButtonClick() {
    // modify the signal value
    this.family().update(p => ({...p, age: 30}));
  }

Summary

Angular Signal is a powerful feature, it

  • provides a easier way to handle lazy loading

  • is more understandable then observable + async pipe to program reactively

  • increases the performance by reducing unnecessary checks the component data and re-rendering.