Introduction
Angular signals represent a paradigm shift in state management and reactivity, offering developers a streamlined, declarative approach. From foundational principles to advanced concepts like testing and RxJS integration, this guide will provide you with everything you need to master Angular signals, supported by detailed examples and practical tips. ๐โจ๐ฏ
Core Concepts
What Are Angular Signals?
Signals in Angular are reactive primitives that track and react to data changes automatically. They reduce boilerplate code and enhance performance by enabling fine-grained reactivity. ๐โก๐
Core Features
- Declarative Reactivity: Signals explicitly declare their dependencies.
- Optimized Change Detection: Updates propagate efficiently, minimizing unnecessary recalculations.
- Debugging Support: Angular provides tools to inspect signal dependencies and values.
Usage of Signals
Creating Signals
Define a signal using the signal
function. Signals hold a reactive value that can be read or updated: ๐ง๐ก๐
import { signal } from '@angular/core';
// Define a signal with an initial value of 0
const counter = signal(0);
// Read the current value of the signal
console.log(counter()); // Outputs: 0
// Update the signal value
counter.set(1);
console.log(counter()); // Outputs: 1
Using Signals in Components
Signals integrate seamlessly into Angular templates: ๐ผ๏ธ๐๐จโ๐ป
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Counter: {{ counter() }}</p>
<button (click)="increment()">Increment</button>
`,
})
export class CounterComponent {
// Define a signal to manage the counter state
counter = signal(0);
// Method to increment the counter
increment() {
this.counter.update(value => value + 1);
}
}
Updating Signals
Signals offer various methods to update their values: ๐ ๏ธ๐พ๐
Signals in Angular provide flexible methods to update their values based on different scenarios:
Using set
The set
method directly assigns a new value to the signal.
import { signal } from '@angular/core';
// Define a signal with an initial value
const counter = signal(0);
// Directly set the signal to a new value
counter.set(10);
console.log(counter()); // Outputs: 10
Using update
The update
method allows you to modify the current value based on its existing state.
import { signal } from '@angular/core';
// Define a signal with an initial value
const counter = signal(0);
// Increment the signal's value by 5
counter.update(value => value + 5);
console.log(counter()); // Outputs: 5
Using mutate
The mutate
method is ideal for modifying complex data structures like arrays or objects directly.
import { signal } from '@angular/core';
// Signal managing a list of numbers
const numbers = signal([1, 2, 3]);
// Mutate the signal by adding an element to the array
numbers.mutate(arr => arr.push(4));
console.log(numbers()); // Outputs: [1, 2, 3, 4]
Summary of Update Methods
-
set
: Use for direct value replacement. -
update
: Use for transformations based on the current value. -
mutate
: Use for in-place modifications of complex structures.
Advanced Topics
Derived Signals
Derived signals use the computed
function to reactively calculate values based on other signals: ๐๐งฎ๐
import { signal, computed } from '@angular/core';
// Define a base signal
const base = signal(5);
// Define a derived signal that depends on the base signal
const double = computed(() => base() * 2);
console.log(double()); // Outputs: 10
// Update the base signal value
base.set(10);
console.log(double()); // Outputs: 20
RxJS Interop with Angular Signals
Angular provides the @angular/rxjs-interop
package for seamless integration between RxJS Observables and Angular signals. This includes utilities like toSignal
and toObservable
.
Creating a Signal from an Observable with toSignal
The toSignal
function converts an Observable into a Signal, allowing you to use Observables reactively in Angular templates or logic.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `{{ counter() }}`,
})
export class Ticker {
counterObservable = interval(1000);
// Convert Observable to Signal
counter = toSignal(this.counterObservable, { initialValue: 0 });
}
-
Automatic Subscription: The subscription created by
toSignal
unsubscribes when the component is destroyed. - Initial Value: You can set an initial value for the signal, which is used before the Observable emits.
Advanced Options for toSignal
-
initialValue
: Define a fallback value before the Observable emits. -
requireSync
: Ensure the Observable emits synchronously (e.g., withBehaviorSubject
). -
manualCleanup
: Manage the Observableโs lifecycle manually if needed.
import { BehaviorSubject } from 'rxjs';
const subject = new BehaviorSubject(42);
const syncSignal = toSignal(subject, { requireSync: true });
console.log(syncSignal()); // Outputs: 42
Errors and Completion
- If the Observable produces an error, the signal throws the error when accessed.
- When the Observable completes, the signal retains the last emitted value.
Injection Context
toSignal
operates within Angular's injection context. If unavailable, you can manually provide an Injector
.
Creating an Observable from a Signal with toObservable
The toObservable
utility converts a Signal into an Observable, making it compatible with RxJS pipelines.
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'app-search-results',
template: `<div>Search Results</div>`
})
export class SearchResults {
query = signal(''); // Define a signal for search queries
// Convert Signal to Observable
query$ = toObservable(this.query);
// Use the Observable in an RxJS pipeline
results$ = this.query$.pipe(
switchMap(query => this.http.get(`/search?q=${query}`))
);
}
-
Timing: Signals stabilize changes before
toObservable
emits. -
Injection Context: Similar to
toSignal
,toObservable
requires an Angular injection context.
Effects
Effects allow you to execute side effects whenever a signalโs value changes: ๐๐โ๏ธ
import { signal, effect } from '@angular/core';
// Define a signal for a count value
const count = signal(0);
// Effect to log the count value whenever it changes
effect(() => {
console.log(`Count is now: ${count()}`);
});
// Update the signal value
count.set(5); // Logs: Count is now: 5
Dependency Tracking
Signals automatically track their dependencies, ensuring updates propagate only when necessary. This reduces unnecessary computations and boosts performance. ๐โกโ
Signal Lifecycles
Signals clean up automatically when a component is destroyed. For manual resource management, use the cleanup
callback: ๐งน๐โป๏ธ
import { effect } from '@angular/core';
// Define an effect with a cleanup callback
effect(() => {
const interval = setInterval(() => console.log('Running...'), 1000);
// Cleanup the interval when the effect is disposed
return () => clearInterval(interval);
});
Testing Angular Signals
Unit Testing Signals
Use Angularโs testing utilities to verify signal behavior: ๐งชโ
๐ฌ
import { signal } from '@angular/core';
describe('Signal Tests', () => {
it('should update signal value', () => {
const count = signal(0);
// Set a new value for the signal
count.set(5);
// Assert the updated value
expect(count()).toBe(5);
});
});
Testing Effects
Mock dependencies and track side effects: ๐ ๏ธ๐๐
import { signal, effect } from '@angular/core';
describe('Effect Tests', () => {
it('should log changes', () => {
const spy = jest.fn();
const count = signal(0);
// Define an effect that logs changes
effect(() => spy(count()));
// Update the signal and assert the effect
count.set(5);
expect(spy).toHaveBeenCalledWith(5);
});
});
Best Practices
- Use Signals for Local State: Avoid using signals for complex, app-wide state. ๐ฏโ๏ธ๐
-
Keep Computed Signals Pure: Avoid side effects in
computed
functions. ๐ฏ๐ ๏ธ๐งผ - Leverage Effects for Side Effects: Separate side effects from state updates. ๐ฏโ๏ธ๐
- Integrate with RxJS: Use signals for lightweight reactivity and RxJS for complex asynchronous data streams. ๐ฏ๐๐
Cheat Sheet
Signal Basics
- Create a signal:
const mySignal = signal(initialValue);
- Read a signal:
mySignal()
- Update a signal:
mySignal.set(newValue);
- Modify a signal:
mySignal.update(value => value + 1);
- Mutate objects/arrays:
mySignal.mutate(obj => { obj.key = value; });
Derived Signals
- Create a derived signal:
const derived = computed(() => mySignal() * 2);
Effects
- Run side effects:
effect(() => console.log(mySignal()));
- Cleanup effects:
return () => cleanupLogic();
RxJS Integration
- Convert to Signal:
toSignal(observable, { options });
- Convert to Observable:
toObservable(signal);
Testing Signals
- Assert signal value:
expect(mySignal()).toBe(expectedValue);
- Test effects: Use spies or mocks to track calls.
Conclusion
Angular Signals represent a significant step forward in state management and reactivity.
They offer:
- Improved performance through fine-grained reactivity
- Cleaner, more maintainable code
- Better developer experience
- Seamless integration with existing Angular features
As you begin implementing Signals in your applications, start small with component-level state and gradually expand to more complex scenarios. The benefits become increasingly apparent as your application grows.
Resources
Remember, Signals are still evolving, and best practices will continue to emerge as the community adopts this powerful feature. Stay tuned for updates and keep experimenting with different patterns in your applications.
Top comments (2)
Hi, the article is a good reference for initiating to signals, but I don't find any reference to mutate method in signals. Where can I find documentation about it?
Thanks
Hey Gerardo, the Angular team has dropped the mutate method for now because of internal code issues it caused. But it might come in the future.
Thanks for pointing this out. :)