Frontend

Angular Signals in 10 minutes | Frontend Weekly vol. 132

Angular team is working on a new reactive primitive called Signals. Integration with the framwork goes very deep as it brings new change detection strategy, new component lifecycle and much more.

Article cover

It seems like the Angular community is talking only about one thing – Signals. The newly published RFC (Request for Comments) is really huge and that’s why we have decided to summarize it. If you want to get the best understanding of Signals and get to know all the design decisions involved, the RFC is still the way to go. Just get ready for quite a long read.

Angular Signals are already available as an experimental feature in Angular 16 Release Candidate. When they will become a stable feature remains unknown. I would recommend arming with patience, as there are still some hot discussion points in the RFC.

Why do we need Angular Signals?

Basically, there are two main reasons: First – zone.js is the biggest pain point for Angular developers. Second – RxJS is not the best abstraction for state management. We will look into both reasons in detail.

zone.js is a library monkey-patching all browser APIs to notify Angular about a possible change in the application. Next Angular runs Change Detection to detect whether something really changed or not. As you can imagine, this behavior can lead to many redundant Change Detection runs. As a result Change Detection in Angular is hard to understand, optimize, and reason about. <a href="https://indepth.dev/posts/1515/deep-dive-into-the-onpush-change-detection-strategy-in-angular" target="_blank" rel="noreferrer noopener">ChangeDetectionStrategy.OnPush</a> makes the situation a little better but the main issues remain the same. Zoneless Angular applications have been an unfulfilled dream for Angular developers for years. Projects like RxAngular reflect the community approach to solving the problem.

RxJS works great whenever we need to model complex events happening over time like backend requests throttling or aggregating mouse clicks. When it comes to managing the state, RxJS is no longer so suitable. To begin with, Observables might not provide us with value. Even if we use BehaviurSubject and shareReplay as much as possible, our code quickly becomes bloated with fallback values and unsafe type casting. Observables are naturally gravitating toward cold streams which leads to many unnecessary calculations and surprising side effects. RxJS also introduces a lot of overhead for beginners. Like what is Subject and BehaviourSubject, what are cold and hot streams, why there are multiple map operators, and when exactly is my code executed.

Signals aim to solve most of the problems mentioned in the above paragraphs. It’s still worth remembering that neither zone.js nor RxJS is going anywhere soon. The fact that there will be an alternative way of doing things doesn’t mean that the old one automatically becomes deprecated.

Discover more IT content selected for you
In Vived, you will find articles handpicked by devs. Download the app and read the good stuff!

phone newsletter image

Angular Signals API

Quoting the original RFC: A signal is a wrapper around a value, which is capable of notifying interested consumers when that value changes. Signals can be created with signal factory method taking the initial value as a parameter. Signals can be composed with computed( and you can perform side effects when signal value changes by calling a effect(). At any time you can read signals value by calling it like a function.

const counterA = signal(2);
const counterB = signal(5);
const counterSum = computed(() => counterA() + counterB());
effect(() => console.log(`Counters sum is ${counterSum()}`);

Note that both computed() and effect() don’t require a dependencies array. Those functions automatically track calls to other signals and then subscribe for the changes. We can even have conditional statements inside them and everything will work just fine.

const counterA = signal(2);
const counterB = signal(5);
const flag = signal(true);

// As long as flag is set to true, changes to counterB will not trigger below computed
const counterSum = computed(() => flag() ? counterA() : counterB());

Signals can be updated with set, update and mutate methods.

const user = signal({id: 1, name: 'Tomek'});

user.set({id: 2, name: 'Iza'});
user.update(user => ({...user, name: 'Izabella'});
user.mutate(user => user.name = 'Izabela');

The last two are just handy util functions as they could be rewritten in the following way

const user = signal({id: 1, name: 'Tomek'});

// user.update(user => ({...user, name: 'Izabella'});
const currentUser = user();
user.set({...currentUser, name: 'Izabella'});

//user.mutate(user => user.name = 'Izabela');
const currentUser = user();
currentUser.name = 'Izabela';
user.set(currentUser);

Whenever the signal value changes, all dependent signals will be marked as dirty and re-evaluated on the next read. If no one is interested in reading signals value there is no point in wasting CPU time. Using the complicated IT jargon – all signals are lazily evaluated.

How do signals detect if the value actually changed? For primitive values, === operator is used. For non-primitive values equality check is skipped and Signals always assume that values have changed. Thanks to this behavior we can avoid the cost-full operation of comparing or copying large objects.

const counterA = signal(10);
effect(() => console.log(counterA()));

// After setting counter to 10 nothing will display in console
counterA.set(10);      

// After setting counter to 15, it will display in console             
counterA.set(15);                                            
// Assume we have a large array full of complicated objects. 
// There is no efficient way to compare 2 arrays like this.
const largeTableOfObjects = signal([/*...*/]);

// Although array reference have not changed
// all signal dependencies will be re-evaluated.
// With referencial equality below code wound not trigger any computations.
largeTableOfObjects.mutate(objects => objects.push({})); 

Of course in real life there will be many situations when comparing two objects, no matter how complicated they are, will be more efficient than re-computing all signal dependencies. To address this scenario we can pass equal function to signal() and computed() methods.

import { isEqual } from 'lodash'

// isEqual might be CPU intensive for large deeply nested objects. 
// For our simple case it works just fine.
const user = signal(
  {id: 1, name: 'Tomek', age: 27},
  {equal: (a, b) => isEqual(a, b)}
);

// We define computed signal that performs some heavy computations
const userDerived = computed(() => heavyComputations(user()))

// Currently none of the below will trigger heavyComputations()
// With default equality behaviour all 3 would trigger heavyComputations()
user.set({id: 1, name: 'Tomek', age: 27});
user.mutate(value => value.age = 27);
user.update(value => ({...value, age: 27}));

We have said that you can update Signals with set(), update() and mutate() methods. This is not completely true as Signals provide two different interfaces: Signal and WritableSignal. The first is read-only and the second includes all the necessary mutation methods. There is no built-in way to convert Signal into WritableSignal. Such API design gives you the ability to explicitly say if you want to emit values or if you want your client to do so.

const writableSignal: WritableSignal<Int> = signal(0);
const readonlySignal: Signal<Int> = signal(0);

const sum: Signal<Int> = computed(
  // You can read from both signals
  () => readonlySignal() + writableSignal() 
); 

// This works perfectly fine.
writableSignal.set(10); 

// TypeScript compiler will throw en error here. 
readonlySignal.set(10); 

// TypeScript compiler will throw en error here. 
// Even casting to any can't help you.
sum.set(10); 

Talking about clear data flow: Angular Signals will prevent you from modifying other signals from computed() and effect() methods. If you do so, the error will be thrown. This is a very nice API design as in such cases you should just use computed.

const countA = signal(0);
const countB = signal(0);
const sumError = signal(countA() + countB());

 // This will thorw an error!
effect(() => sumError.set(countA() + countB()));

// This is the correct way to define sum of signals
const sumCorrect = computed(() => countA() + countB());

Angular Signals integration with Angular

To start using Signals in your Angular components, all you have to do is add signals: true to your @Component decorator. This flag modifies the change detection strategy for your component. From now on, it will only re-render when one of the signals used in the component changes.

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <p>Count: {{ count() }}</p>
  <p>Double Count: {{ doubleCount() }}</p>
  <button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
  count = signal(0); 
  doubleCount = computed(() => 2 * count());

  increment() {
    this.count.update(c => c + 1);
  }
}

If your component is signal-based it means that it is completely zoneless! At least until you start using the old zone.js components within it. In such cases, Angular will scope change detection. Our component will still re-render only when one of the internal signals changes while the child component will use the old good zone.js change detection.

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <p>Count {{ count() }}</p>
  <zone-component />
  <button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
  count = signal(0);

  increment() {
    this.count.update(c => c + 1);
  }
}

One important amplification of this new change detection strategy is the fact that you no longer can imperatively modify component properties and expect UI to re-render. You can still use class properties in your template, but they will only refresh when one of the signals changes.

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <p>Signal Count {{ count() }}</p>
  <p>Imperative Count {{ countImperative() }}</p>
  
  <!-- Clicking this button won't have any effect in the UI -->
  <button (click)="incrementImperative()">Increment Imperative Count</button>

  <!-- Clicking this button will update both counts in the UI -->
  <button (click)="incrementSignal()">Increment Signal Count</button>`
})
export class SimpleCounter {
  count = signal(0);
  countImperative = 0;

  incrementImperative() {
    countImperative = countImperative + 1;
  }

  incrementSignal() {
    this.count.update(c => c + 1);
  }
}

In Signal based components you also define input and outputs quite differently. Instead of decorating properties with @Inptut() and @Output() decorators you use input() and output() functions. Please note the return type of input() functions. A clear split between read-only and writable signals forces you to design a clear, one-directional data flow.

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <p>Count {{ count() }}</p>
  <button (click)="onClick()">Increment Count</button>`
})
export class SimpleCounter {
  count: Signal<number> = input<number>(0);
  buttonClick: EventEmitter<number> = output<void>();

  onClick() {
    this.buttonClick.emit();
  }
}

The last important difference between regular components and Signal based components is the new lifecycle. As change detection works quite differently, the old lifecycle methods are no longer suitable. With the new approach instead of implementing interfaces we are registering callbacks with magic hook functions.

@Component({
  signals: true,
  selector: 'user-profile',
  template: `
    <h2>Hello {{name()}}!</h2>`,
})
export class LifecycleComponent {
  name = signal('Tomek');

  constructor() {
    afterInit(() => {
      // All inputs have their initial values.
    });
    
    afterRender(() => {
      // After the DOM of *all* components has been fully rendered.
    });
    
    afterNextRender(() => {
      // Same as afterRender, but only runs once.
    });

    afterRenderEffect(() => {
      // Same as afterRender in terms of timing, 
      // but runs whenever the signals which it reads have changed.
      console.log(`DOM was updated due to '${this.name()}'`);
    });
    
    beforeDestroy(() => {
      // This component instance is about to be destroyed.
    });
  }
}

The magic hook approach gives us some incredible possibilities like registering a render or destroy callback on a button click.

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <p>Count {{ count() }}</p>
  <button (click)="increment()">Increment count</button>
  <button (click)="addCallbacks()">Add callbacks</button>`,
})
export class SimpleCounter {
  count = signal(0);

  increment() {
    this.count.update(c => c + 1);
  }

  addCallbacks() {
    afterRenderEffect(() => {
      // I need to get notified the next time name changes!
      console.log(`Count changed: '${this.count()}'`);
    });

    beforeDestroy(() => {
      // Because I have initialized some stuff in my function
      // Now I have to do some additional cleanup 
      unsubscribe();
    });
  }

  private unsubscribe() { /*...*/ }
}
Discover more IT content selected for you
In Vived, you will find articles handpicked by devs. Download the app and read the good stuff!

phone newsletter image

Angular Signals and RxJS Interoperation

As Signals and RxJS will coexist in Angular applications it’s really important to have good tooling to switch between those abstractions. Such tooling will be also heavily used by Angular internally, as the team plans to provide both Signals and RxJS APIs for most of the parts of the framework.

fromObservable the function converts an observable into a Signal. The second argument provided to the function is the value stored in the Signal until the observable emits. If we will not provide the default value and we will try to access the signal before the observable emits, the error will be thrown. It’s also important to notice, that fromObservable will immediately subscribe to a given observable to avoid side effects triggered by reading a signal.

const mySignal = fromObservable(myObservable$);

fromSignal converts an observable into a signal. Conversion in this way is much simpler as Observables are much more flexible.

const myObsrevable$ = fromObservable(mySignal);

Disclaimer: This article is based on the first version of the RFC. This means that many things in the final API might change. I will try to do my best to keep this article as much up to date as possible. If you have spotted a mistake in the article please let me know on Twitter.