Angular Signals

Introducing Angular Signals

Have you ever wondered how we could enhance the speed and efficiency of our Angular applications while also avoiding unnecessary updates? Just imagine being able to focus on rendering the specific parts of the app that have changed, without having to waste time checking the whole component tree every time. As we all know, using change detection in Angular can be quite tricky and can drastically impact performance if not used properly.

We have found that Angular signals, which allow for precise updates in the application, are incredibly effective. They are very easy to learn and you can start testing them in minutes.

What is Angular Signal?

Angular's team made a simple pull request on February 15th, 2023, which introduced Signals to the framework. They have been working for some period, and now finally they have a result.

Angular's signal type is a modern reactive primitive that allows you to store values similar to variables, but with a unique feature. Signals can notify any dependent elements when they change. By using signals, Angular can detect and trigger changes more efficiently than the traditional method of checking the entire component tree. This innovative approach offers better performance, greater precision, and more efficient handling of changes in the application.

The goal of Angular's team is: to embrace fine-grained reactivity in the core of the framework.

It's possible that significant updates are coming, which could eliminate our dependence on Zone.js.

Another argument for this is the following from the team: We want to change the underlying change detection system for Angular.

Are they ready to use?

Even though Angular made a pull request with this feature, it is not production-ready yet. We don't know when exactly it will be available.

In the pull request from 15 February, they said: Sometime later this year, depending on how smoothly the prototyping efforts progress.

See the discussion here

Using Angular Signals

If you want to try out Angular's new signal feature, you'll need to get the latest pre-release version, which is 16.0.0-next.0. This version has the new signals included in the public API. However, since it's still in the prototype phase, it's recommended not to use it for production purposes.

  • For existing project, you may upgrade to it

    ng update @angular/cli @angular/core --next
  • If you are about to create new project

    npx @angular/cli@next new smart-angular-signals

Since we have a project with the correct version, let's test and explore the API.

The main idea of Angular Signals is to avoid rerendering the whole tree or the whole component, but only the part that has changed

What is signal()?

The signal is a value that can be tracked and when it changes, it automatically notifies all of its dependencies.

An example of this is:

const text = signal('default text');

When the signal() is invoked, it returns an object that contains getters, setters and a value. In this case, the text constant is this object.

The signal is an argument-free function because it doesn't create any side effects when we access its value.

A side effect happens when a function relies on an external, async or a random code. It is called side effect, because you are creating unpredictable behaviour.

The following example is a side effect because we don't know what will be the value of the text after invoking the function.

let text = 'default';

function addToText() {
    text += Math.floor(Math.random() * 10)
}

Changing the value

Our return type of the signal function is WritableSignal<T>. The interface contains the following methods:

set(value: T): void;
update(updateFn: (value: T) => T): void;
mutate(mutatorFn: (value: T) => void): void;

So to change the value and notify the signal's dependencies, we have 3 options:

  1. set(value: T): This function replaces the current value.
    text = signal('default text');
    text.set('new text');
  2. update(updateFn: (value: T) => T) : The update will change the value based on the current one.

    Important notice: the updates should be always immutable!

    text = signal('default text');
    text.update(currentText => currentText + ' is cool');
  3. mutate(mutatorFn: (value: T) => void) : This is a sugar syntax for the update method if we want to avoid returning a new copy of the value.

    With the mutate we can directly mutate, the current value without worrying about immutability

    type Info = {
        name: string,
        age: number
    }
    
    info = signal<Info>({ name: 'Patrick', age: 24 });
    info.mutate(currentInfo => { 
        currentInfo.age = 23; 
    });

Equal function

The signal() method has one optional argument, which is options (CreateSignalOptions<T>). The 'options' argument has an 'equal' function, which may be set to check if the new value is the same as the old one. The type of the 'equal' function is the following ValueEqualityFn<T> = (a: T, b: T) => boolean

If the function returns true, the signal's value is not updated, and the dependencies will not get notified because there isn't an actual update. This way, you will avoid any unnecessary updates

info = signal(
    { name: 'Patrick', age: 24 },
    {
        equal: (a: Info, b: Info) => {
          if (a.name === b.name && a.age === b.age) { return true }
          else { return false }
        }
    }
)

What is computed()?

To create a reactive value (a dependency of the signal), you should use the computed function.

The type of the function is the following: computed(computation: () => T, options?: CreateComputedOptions<T>): Signal<T>

info = signal(
    { name: 'Patrick', age: 24 },
    {
        equal: (a: Info, b: Info) => {
            if (a.name === b.name && a.age === b.age) { return true }
            else { return false }
        }
    }
)

isAdult = computed(() => this.info().age >= 18);

console.log(this.isAdult()); //true

this.info.mutate(currentInfo => {
    currentInfo.age = 16;
})

console.log(this.isAdult()); //false

In the above example, we created a dependency of the 'info' called 'isAdult'. Whenever the 'info' signal changes, the isAdult's value will also change because it is a dependency of 'info'.

You may have seen that the return type of the computed is a signal. That means that it can have its dependencies also.

What is effect?

effect() is a function which performs side effects when the signal inside notifies for a change. The signals inside the function are the dependencies of the effect(). If a dependency inside the function changes, the side effect is performed.

info = signal(
    { name: 'Patrick', age: 24 },
    {
        equal: (a: Info, b: Info) => {
          if (a.name === b.name && a.age === b.age) { return true }
          else { return false }
        }
    }
)

effect(() => {
    const infoValue = this.info();
    console.log(`The information has been changed: name: ${infoValue.name}, age: ${infoValue.age}`);
})

this.info.set({ name: 'Patrick', age: 14 })
this.info.set({ name: 'Patrick', age: 14 })

//The information has been changed: name: Patrick, age: 14

See that the effect executes only once because our equality function of the signal detects no change in the value therefore, the notification is blocked.

Angular Signals Example with Smart UI

If you have already installed the version (16.0.0-next.0), paste this example and test how the signals work.

Install @smart-webcomponents-angular/button

ng add @smart-webcomponents-angular/button

Install @smart-webcomponents-angular/numberinput

ng add @smart-webcomponents-angular/numberinput

Add the required styles in angular.json

"styles": [
    "src/styles.css",
    "./node_modules/@smart-webcomponents-angular/button/styles/smart.base.css",
    "./node_modules/@smart-webcomponents-angular/button/styles/smart.common.css",
    "./node_modules/@smart-webcomponents-angular/button/styles/smart.button.css",
    "./node_modules/@smart-webcomponents-angular/numberinput/styles/smart.numberinput.css"
]

app.component.ts

import { Component, signal, computed, ViewChild } from '@angular/core';
import { NumberInputComponent } from '@smart-webcomponents-angular/numberinput';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {
    title = 'smart-angular-signals';

    @ViewChild('numberInput', { read: NumberInputComponent, static: false }) numberInput!: NumberInputComponent;
    
    sum = signal(0);

    isEven = computed(() => {
        if (this.sum() % 2 === 0) {
            return true
        } else {
            return false
        }
    });

    handleAddButtonClick() {
        const value = Number(this.numberInput.value);

        this.sum.update(current => {
            return current += value
        })
    }

    handleSetButtonClick() {
        const value = Number(this.numberInput.value);

        this.sum.set(value)
    }
}

app.component.html

<div style="font-family: var(--smart-font-family);">
    <div style="display: flex; gap: 20px;">
        <smart-button (onClick)="handleAddButtonClick()">Add</smart-button>
        <smart-button (onClick)="handleSetButtonClick()">Set</smart-button>
        <smart-number-input #numberInput />
    </div>
    <p>The sum is: {{sum()}}</p>
    <p>The sum is even: {{isEven()}}</p>
</div>

app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';

import { ButtonModule } from '@smart-webcomponents-angular/button';
import { NumberInputModule } from '@smart-webcomponents-angular/numberinput';

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        BrowserModule,
        ButtonModule,
        NumberInputModule
    ],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule { }

The result is the following:

Add Set

The sum is: 0

The sum is even: true

Angular without Zone.js

With the default strategy, Angular will have no assumption on the component's dependency and will check every component from the component tree from top to bottom every time an event triggers change detection on browser async events.

An example of async event:

@Component({
    selector: 'app-root',
    template: 'My sum is {{sum}}!'
})
export class AppComponent implements OnInit {
    sum: number = 0;

    ngOnInit() {
        setTimeout(() => {
            this.sum = 333;
        }, 500);
    }
}

The steps of updating the component are:

  1. Zone.js detects a change
  2. Zone.js runs a signal for detecting the change
  3. Rerender after the update

This automatic change detection can be double-edged sword if not used properly. It can causes serious performance issues. There is also an alternative, use Angular without Zone.js and trigger changes manually.

No Zone.js

To turn-off the automatic change detection (Zone.js), in the bootstrapModule function, set the ngZone to noop

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic()
   .bootstrapModule(AppModule, { ngZone: 'noop' })
   .catch((err) => console.error(err));

Now the detection of the changes is in your hands. This approach for a performant application requires deep knowledge!

This is the example from above but now with manual change detection:

@Component({
    selector: 'app-root',
    template: 'My sum is {{sum}}!'
})
export class AppComponent implements OnInit {
    sum: number = 0;

    constructor(private cdr: ChangeDetectorRef) {}

    ngOnInit() {
        setTimeout(() => {
            this.sum = 333;

            this.cdr.detectChanges();
        }, 500);
    }
}

Now our application will be very performant but with one huge disadvantage: we have to manually trigger the changes in the application. As previously said, knowledge of when to trigger a change is required.

Updates using Angular Signals

The best combination would be automatic updates only for the places of the updates, not in the entire template. For this, the Angular Signals come into play. They help avoid checking the whole component, but only the part that changed.

Using Angular Signals may help us make granular updates, as said from Angular's team, without re-rendering whole components. This may remove the Zone.js from the game.

RxJS and Angular Signals

RxJS is deeply connected with Angular and it is the core of the reactivity in Angular. It offers a great way to overcome some difficulties with its features.

RxJS is a great library, but it has its disadvantage: the learning curve can be big for beginners.

If we compare Angular Signals with Subjects in RxJS we can see:

  • The signals are easier to learn and do not require learning a library in the beginning.
  • The reactive primitive has a dependency tree that notifies itself of any updates, there is no need to trigger updates in the code.

A combination of the powerful operators of RxJS and the signals would be very beneficiary for us!

Summary of the benefits:

  • granular updates
  • great reactive primitive
  • no side effects, but a possibility for ones
  • auto detect changes
  • integration with RxJS
  • improve performance
  • reduce application bundle size
  • beginners can skip RxJS
  • usage even outside the components

Everything comes with its downsides, here are the downsides of the signals

The learning curve can be very big if newcomers want to use signals with RxJS. The another problem, which may be resolved in the near future, is the invocation of the methods inside the template.

Conclusion

We are very exited to see the production use of the signals. They will finally improve the performance of the Angular applications. We believe that signals will be the primary way of the change detection.