Angular Signals: A Deep Dive

Atakan Korez
11 min readOct 8, 2023

--

In this blog, we will thoroughly examine Signals, a new feature introduced with Angular 16, which makes life easier for developers and improves application performance while also reducing costs. Signals provide a new way to capture when a variable undergoes a data change, making the code more reactive. So, let’s get started.

What are Signals?

Signals are a new feature that tracks changes in a variable’s value and notifies us reactively when a change occurs. To better understand this, let’s proceed with the example below:

let x = 50;
let y = 100;
let z = x + y;
console.log(z); // 150
x = 100;
console.log(z); // 150

If you look at the code above, you can see that the variable z is calculated from the variables x and y, and the result is printed to the console on line 4. Everything seems normal so far, but do you think changing the value of the x variable on line 5 will make any difference in the value of z on line 6? Of course not, right? That's because the value of z will remain independent of x and y from line 3 onwards, and any changes to x or y during the process will not affect z!

However, if you want the code to react to changes in x or y, or in other words, if you want z to be recalculated when x or y values change, you need to be more reactive. You can use a getter property like the one below for that purpose:

get total() {
return this.x + this.y;
}

Yes, this way you can achieve reactive behavior. However, if you want to apply this directly to a variable when a change occurs, you can use Signals like this:

let x = signal(50);
let y = 100;
let z = computed(() => x() + y);
console.log(z()); // 150
x.set(100)
console.log(z()); // 200

As you can see, the signal function is used to track changes in the x variable, and the computed function is used to recalculate the value of z when this variable changes. The set function is used to change the value, as shown in line 5. Of course, we will explore these functions and their purposes in detail in the following lines. For now, we have provided a simple example to show you how reactive behavior can be easily achieved with the Signals feature.

Long story short, Signals allow us to dynamically track the values of variables (at the variable level) in Angular applications and take actions with various qualities in case of possible changes. This enables us to easily perform our desired tasks along with these actions, improving application performance and providing an effective user experience in terms of change detection.

Zone.JS is a runtime model that allows Angular to work effectively with asynchronous operations and handle events efficiently. It regulates the interactions of components and other parts of an Angular application, optimizing performance. In other words, Zone.JS ensures that components respond to changes in any part of the DOM where changes occur, regardless of their location. So, we can say that Signals work independently of Zone.JS in Angular!

How to Define and Use Signals?

To define a signal, we use the signal function found in the @angular/core path.

import { signal } from '@angular/core';
// ...
let x = signal(50);

As you can see in the code above, a signal with a value of 50 has been defined. When we inspect the type of this signal, we will see that it is of type WritableSignal. In other words, it can be both read and written. To make this more explicit, you can specify the type of the created signal as follows:

let x: WritableSignal<number> = signal(5);

During the definition process, you can also specify the type of the signal as a generic parameter:

let x: WritableSignal<string> = signal<string>("hello");

To use a defined signal or, in other words, to read the value of a signal, you can call it as a function when needed. Note that the parentheses should be empty as shown below:

x(); // This is how you read the value of the signal x

Now let’s take a look at important functions that allow us to perform actions on signals.

set Function

The set() function allows us to assign a different value to a signal besides its initial value or update the existing value.

let x: WritableSignal<number> = signal<number>(20);
console.log(x()); // 20
x.set(45);
console.log(x()); // 45

It’s crucial to know the following details about the set() function: If the set() function is called consecutively on a signal and assigns the same value, this will trigger a change in the signal value as if it has been changed. This is extremely important for scenarios where you need to react to changes in a value, as it ensures that your signal reacts even when the value assigned is the same as the previous one.

let x: WritableSignal<number> = signal<number>(20);
console.log(x()); // 20
x.set(20); // Although the same value is assigned, it will trigger change detection
console.log(x()); // 20

update function

The update() function is used to update a signal to a new value that is based on the current value. It takes a function as an argument that returns the new value.

// Create a signal.
const count = signal(5);

// Update the signal using the update() method.
count.update(value => value + 1);

// Log the new value.
console.log(count.value); // 6

mutate function

The mutate() function is used to directly modify the current value of a signal. It takes a function as an argument that performs the mutation.

// Create a signal.
const users = signal([]);

// Add a new user to the signal using the mutate() method.
users.mutate(users => users.push({ name: 'Atakan' }));

// Log the new value.
console.log(users.value); // [{ name: 'Atakan' }]

When to use the update() or mutate() method depends on the specific situation. If you need to update a signal to a new value that is based on the current value, then use the update() method. If you need to directly modify the current value of a signal, then use the mutate() method.

subscribe Function

The subscribe() function allows us to monitor changes in the value of a signal and perform specific actions when a change occurs.

let x: WritableSignal<number> = signal<number>(20);
const subscription = x.subscribe((newValue, oldValue) => {
console.log(`Value changed from ${oldValue} to ${newValue}`);
});

x.set(40); // This will trigger the subscription callback

In this example, a subscription is created to the signal x, and whenever the value of x changes (as a result of using the set() function), the callback provided to subscribe() is executed. This way, you can react to changes in the value of a signal and perform actions accordingly.

Remember to unsubscribe from the subscription when it’s no longer needed to prevent memory leaks!

map Function

The map() function allows us to create a new signal that derives its value from an existing signal, transforming it in the process.

let x: WritableSignal<number> = signal<number>(20);
let y = x.map((value) => value * 3);
console.log(y()); // 60

x.set(25); // This will trigger a change in y as well
console.log(y()); // 75

In this example, the signal y is derived from the signal x using the map function. The transformation function multiplies the value of x by 2, resulting in a new signal y with the updated value when x changes.

Computed Functions

Computed functions are a powerful way to create signals that automatically recalculate their values based on other signals. These functions are created using the computed() function.

import { computed } from '@angular/core';
let x = signal(20);
let y = signal(30);
let z = computed(() => x() + y());
console.log(z()); // 50
x.set(25); // This will trigger a recalculation of z
console.log(z()); // 55

In this example, we create a computed signal z that calculates its value based on the values of signals x and y. When either x or y changes, z is automatically recalculated, ensuring that it always reflects the correct value.

effect function

When the value of a signal changes, we may want to execute some code. To achieve this, we can use the effect function, which can only be used at the constructor level.

@Component({
selector: 'app-root',
standalone: true,
template:
'<button (click)="onSet()">set</button> <br>{{x()}}',
})
export class AppComponent {
constructor() {
effect(() => console.log("Current value of x: ${this.x()}"));
}

x: WritableSignal<number> = signal<number>(1);

onSet() {
this.x.update((data) => data + 10);
}
}

As seen in the example above, with the effect function defined in the constructor, we can perform additional actions in response to possible data changes in the “x” signal. The effect function always runs the change detection process asynchronously.

The effect function can be used for various purposes, such as logging data during changes, synchronizing data to local storage during changes, or adding custom DOM behaviors. Additionally, when an effect is created, it will automatically be destroyed when the component, directive, or pipe to which it belongs is destroyed. If you wish to manually destroy an effect, you can use the destroy function as follows:

const _effect: EffectRef = effect(() => /* ... */);
_effect.destroy();

Under normal circumstances, you cannot change the value of a signal within effectand computedfunctions. If you need to change it, you should do so through the allowSignalWrites property, although this approach is not generally recommended:

effect(() => {
console.log("Current value of x: ${this.x()}");
this.x.update((data) => data + 10);
}, { allowSignalWrites: true });

It’s important to note that allowing signal value changes within an effectusing the allowSignalWritesproperty is not recommended.

cleanup function

In cases where an effect is triggered serially due to frequent changes in the “x” signal, and you want to stop the previous effect’s process before starting a new one, you can use the cleanup function. The cleanup function is a callback function called before the next effect starts or when the relevant effect is destroyed:

effect((onCleanup) => {
console.log("Current value of x: ${this.x()}");
const timer = setTimeout(() => {
console.log("Processing...");
}, 2000);
onCleanup(() => {
clearTimeout(timer);
});
});

The cleanup function ensures that previous effects are destroyed when a new one is fired. Finally, if you want to prevent any effects from being triggered when the value of a signal changes, you can use the untracked”function. Code within the untracked function will not be considered as a dependency by effects:

effect(() => {
console.log("Current value of x: ${this.x()}");
untracked(() => {
this.y.update((data) => data + 1);
console.log("Current value of y: ${this.y()}");
});
});

In the code above, the untracked function ensures that the operations it contains will not create dependencies for effects. Therefore, the “y” signal’s value will only trigger the effect when the “x” signal’s value changes.

Derived Signals

Derived signals are signals that are derived from one or more source signals using various transformation operations. These operations can include arithmetic operations, filtering, mapping, and more.

Here’s an example of creating derived signals:

import { signal, computed, filter } from '@angular/core';

let x = signal(5);
let y = signal(10);

// Create a computed signal that multiplies x and y
let multiplied = computed(() => x() * y());

// Create a filtered signal that only emits even numbers
let even = filter((value) => value % 2 === 0, multiplied);

console.log(multiplied()); // 50
console.log(even()); // 10

x.set(3);
console.log(multiplied()); // 30
console.log(even()); // No change, still 10

Batching Changes

Signals offer a built-in way to batch changes, which can be useful when you want to perform multiple updates to signals without triggering multiple change detection cycles.

You can use the batch() function to group multiple changes together:

import { signal, batch } from '@angular/core';

let x = signal(10);
let y = signal(0);

batch(() => {
x.set(30);
y.set(40);
});

// Change detection will only run once for both updates

In this example, the x and y signals are updated within a batch function. This ensures that change detection runs only once for both updates, improving performance when making multiple changes in succession.

RxJS Interop

In Angular 16, there are functions in the @angular/core/rxjs-interop path, namely toSignal and toObservable for transforming observables into signals and vice versa. Both functions should be used at the constructor level. Using them elsewhere may result in errors.

toSignal Function

The toSignal function allows you to transform an observable object into a read-only signal (meaning it has a Signal reference).

export class AppComponent {
constructor() {
effect(() => console.log("Value of the counter signal: ${this.counter()}"));
}
counter$ = interval(1000);
counter = toSignal(this.counter$);
}

As seen in the example above, an observable with a 1-second interval is created, and the toSignal function is used to transform it into a signal. Consequently, the value of the counter signal will change every second, triggering the effect function accordingly.

It’s important to note that the toSignal function will immediately subscribe to the observable. This behavior can lead to unintended side effects in some cases.

Additionally, observables do not have an initial value by default. Therefore, when using the toSignal function, you may want to provide an initial value to the resulting signal using the initialValue property:

export class AppComponent {
constructor() {
effect(() => console.log("Value of the counter signal: ${this.counter()}"));
}
counter$ = interval(1000);
counter = toSignal(this.counter$, { initialValue: 999 });
}

Alternatively, you can provide an initial value to the resulting signal if the source observable immediately emits data by setting the requiresSync property to true:

export class AppComponent {
constructor() {
effect(() => console.log("Value of the counter signal: ${this.counter()}"));
}
counter$ = interval(1000);
counter = toSignal(this.counter$, { requiresSync: true });
}

Finally, if a transformed observable using the toSignal function does not immediately emit data, Angular will throw an error. To resolve this issue, you can provide an initial value to the source observable, as shown below:

export class AppComponent {
constructor() {
effect(() => console.log("Value of the counter signal: ${this.counter()}"));
}
counter$ = interval(1000).pipe(startWith(0));
counter = toSignal(this.counter$, { requiresSync: true });
}

toObservable Function

The toObservable function is used to transform a signal into an observable object. Here’s an example of how it can be used:

count: WritableSignal<number> = signal(1);
count$ = toObservable(this.count);

increment() {
setTimeout(() => {
this.count.update((data) => data + 1);
this.increment();
}, 1500);
}

ngOnInit() {
this.increment();
this.count$.subscribe((data) => console.log(data));
}

In this example, the count signal is transformed into an observable, and the increment function continuously updates the signal’s value. The ngOnInit function subscribes to the observable and logs its values.

Benefits of Using Signals

Now that you have an understanding of what signals are and how to use them, let’s explore some of the benefits of using signals in Angular applications:

  1. Reactivity: Signals provide a more reactive way to work with data in Angular. You can easily track changes and react to them without relying solely on Angular’s change detection.
  2. Performance: Signals can lead to better performance in your Angular applications. By tracking changes at a granular level, you can minimize unnecessary re-renders and improve the overall efficiency of your app.
  3. Simplified Code: Signals can simplify your code by reducing the need for complex change detection strategies and event handling. This can make your codebase cleaner and easier to maintain.
  4. Independence from Zone.JS: Signals work independently of Zone.JS, which can be advantageous in scenarios where you want more control over change detection or when working with libraries that don’t play well with Zone.JS.
  5. Efficient State Management: Signals can be used for state management within your Angular components. You can create signals for different pieces of state and easily compose them to derive new state values.
  6. Batching Changes: Signals provide a built-in mechanism for batching changes, which can be useful when you need to make multiple updates to signals in a single operation, reducing unnecessary change detection cycles.

Conclusion

Signals provide a powerful and efficient way to handle data reactivity and state management in Angular applications. By offering fine-grained control over change detection and updates, signals can help you create more responsive and performant Angular components.

While signals offer these advantages, it’s essential to consider your specific use case and project requirements when deciding whether to use signals in your Angular application. Signals can be a powerful tool, particularly in scenarios where fine-grained reactivity and performance optimizations are crucial.

Whether you choose to use signals for your entire application or for specific components, understanding how to work with signals and their associated functions can be a valuable addition to your Angular development toolkit. Experiment with signals in your projects to see how they can simplify your code, improve reactivity, and boost the overall performance of your Angular applications.

--

--

Atakan Korez

Senior Software Engineer | .NET | C# | SQL | Full-Stack | Angular 2+ | Azure | DevOps. Find me at linkedin.com/in/atakankorez/