Angular Signals: The Reactivity Revolution
Angular Signals represent the most significant change to Angular's reactivity model since the framework's creation. Introduced in Angular 16 and progressively enhanced through versions 17, 18, 19, 20, and 21, Signals provide a fine-grained, synchronous reactivity primitive that eliminates the need for Zone.js and fundamentally changes how we think about state management in Angular applications.
In this article, the first of the Modern Angular series, we will explore Signals from the ground up: what they are, how they work internally, and how to use them effectively in production applications. By the end, you will have a solid understanding of writable signals, computed signals, effects, signal-based inputs and outputs, and the migration path from Zone.js.
What You Will Learn
- What Signals are and why Angular needed them
- How to create, read, and update writable signals
- Derived state with
computed()and dependency tracking - Side effects with
effect()and cleanup functions - Signal-based inputs, outputs, and model signals
- Signal queries with
viewChild()andviewChildren() - Practical patterns for forms, API calls, and shared state
- Migrating from Zone.js with
toSignal()andtoObservable() - Best practices and common pitfalls to avoid
Modern Angular Series
| # | Article | Topic |
|---|---|---|
| 1 | Angular Signals: From Zero to Production | You are here |
| 2 | Zoneless Change Detection | Removing Zone.js |
| 3 | New Template Syntax: @if, @for, @defer | Control flow blocks |
| 4 | Standalone Components | Module-free architecture |
| 5 | Signal Forms | Reactive forms with Signals |
| 6 | Angular SSR and Incremental Hydration | Server-side rendering |
| 7 | Core Web Vitals in Angular | Performance optimization |
| 8 | Angular PWA | Progressive Web Apps |
| 9 | Advanced Dependency Injection | DI patterns and tokens |
| 10 | Migrating from Angular 17 to 21 | Step-by-step migration |
1. The Problem: Why Zone.js Had to Go
To understand why Signals were introduced, we need to understand the problem they solve.
Since its inception, Angular relied on Zone.js to detect changes in the
application state. Zone.js works by monkey-patching every asynchronous browser API:
setTimeout, setInterval, Promise, DOM event listeners,
XMLHttpRequest, and many more.
Every time any asynchronous operation completes, Zone.js notifies Angular, which then runs change detection across the entire component tree. This means that even if a single button click updates one variable in one component, Angular checks every single binding in every single component to see if anything changed.
The Zone.js Performance Problem
- Coarse-grained detection: Every async event triggers a full tree check, even for unrelated changes
- Unnecessary checks: Components that did not change are still checked for dirty bindings
- Bundle size overhead: Zone.js adds roughly 100KB to the application bundle
- Third-party conflicts: Monkey-patching browser APIs can break libraries that expect native behavior
- Debugging difficulty: Stack traces become polluted with Zone.js frames, making debugging harder
Signals solve all of these problems by introducing a push-based, fine-grained reactivity system. When a signal value changes, only the components that actually depend on that signal are re-rendered. There is no tree walking, no dirty checking, and no Zone.js overhead.
Zone.js vs Signals: A Comparison
| Aspect | Zone.js (Legacy) | Signals (Modern) |
|---|---|---|
| Detection model | Pull-based (dirty checking) | Push-based (dependency tracking) |
| Granularity | Entire component tree | Individual bindings |
| Trigger | Any async operation | Only signal value changes |
| Bundle impact | ~100KB extra | Built into Angular core |
| Synchronous | No (requires microtask) | Yes (immediate reads) |
| Third-party compat | Potential conflicts | No monkey-patching |
2. Anatomy of a Signal
A Signal is a wrapper around a value that notifies interested consumers when that value changes. At its core, a signal is a zero-argument function: you call it to read the current value, and Angular's reactive system tracks which computations and templates depend on it.
Creating a Writable Signal
The signal() function creates a WritableSignal<T> that holds
a value you can read and update.
import { signal } from '@angular/core';
// Create a writable signal with an initial value
const count = signal(0); // WritableSignal<number>
const name = signal('Angular'); // WritableSignal<string>
const items = signal<string[]>([]); // WritableSignal<string[]>
// Read the current value by calling the signal as a function
console.log(count()); // 0
console.log(name()); // 'Angular'
console.log(items()); // []
// Set a new value with .set()
count.set(5);
console.log(count()); // 5
// Update based on the current value with .update()
count.update(current => current + 1);
console.log(count()); // 6
// For objects/arrays, use .update() to maintain immutability
items.update(list => [...list, 'New Item']);
WritableSignal vs Signal (ReadonlySignal)
TypeScript distinguishes between WritableSignal<T> (supports
.set() and .update()) and Signal<T>
(read-only, only callable as a function). When you expose a signal from a service,
you should return the read-only type to prevent consumers from modifying the state directly.
import { Injectable, signal, Signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
// Private writable signal
private _count = signal(0);
// Public read-only signal
readonly count: Signal<number> = this._count.asReadonly();
increment(): void {
this._count.update(c => c + 1);
}
decrement(): void {
this._count.update(c => c - 1);
}
reset(): void {
this._count.set(0);
}
}
Equality and Change Detection
By default, signals use Object.is() to determine whether a value has actually
changed. If you set a signal to the same value it already holds, dependents will
not be notified. For complex objects, you can provide a custom equality function.
interface User {
id: number;
name: string;
email: string;
}
// Custom equality: only re-notify if the ID changes
const currentUser = signal<User>(
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ equal: (a, b) => a.id === b.id }
);
// This will NOT trigger dependents (same ID)
currentUser.set({ id: 1, name: 'Alice Updated', email: 'alice@new.com' });
// This WILL trigger dependents (different ID)
currentUser.set({ id: 2, name: 'Bob', email: 'bob@example.com' });
3. Computed Signals: Derived State
A computed() signal automatically derives its value from other signals.
It is lazy (only recalculated when read), memoized
(cached until dependencies change), and read-only. Computed signals
are the primary mechanism for building derived state in Angular.
import { signal, computed } from '@angular/core';
const price = signal(100);
const quantity = signal(3);
const taxRate = signal(0.22);
// Computed signal: automatically tracks price, quantity, taxRate
const subtotal = computed(() => price() * quantity());
const tax = computed(() => subtotal() * taxRate());
const total = computed(() => subtotal() + tax());
console.log(total()); // 366
// When price changes, subtotal, tax, and total are all invalidated
price.set(200);
console.log(total()); // 732
// Computed signals can depend on other computed signals
const formattedTotal = computed(() => `${total().toFixed(2)} EUR`);
Key Properties of Computed Signals
- Lazy evaluation: The computation function only runs when the computed signal is actually read
- Memoization: If the result has not changed (by equality check), dependents are not notified
- Automatic dependency tracking: Angular tracks which signals are read during the computation
- Glitch-free: Computed signals always reflect a consistent state; you never see intermediate values
- Read-only: You cannot call
.set()or.update()on a computed signal
Conditional Dependencies
An important subtlety of computed signals is that dependencies are tracked dynamically based on which signals are actually read during the last execution. If a conditional branch prevents a signal from being read, it is not a dependency for that evaluation.
const showDetails = signal(false);
const basicInfo = signal('Basic info');
const detailedInfo = signal('Detailed info');
const displayText = computed(() => {
if (showDetails()) {
// When showDetails is true, depends on: showDetails, detailedInfo
return detailedInfo();
}
// When showDetails is false, depends on: showDetails, basicInfo
return basicInfo();
});
// Changes to detailedInfo do NOT trigger recomputation
// because showDetails() is false (detailedInfo is not read)
detailedInfo.set('New detailed info');
console.log(displayText()); // 'Basic info' - no recomputation needed
4. Effects: Side Effects in the Signal World
While computed signals derive new values, effect() executes side effects
whenever the signals it reads change. Effects are ideal for logging, analytics, DOM manipulation,
synchronizing with external systems, or persisting state to localStorage.
import { signal, effect, Component } from '@angular/core';
@Component({
selector: 'app-user-profile',
standalone: true,
template: `<p>User: {{ username() }}</p>`
})
export class UserProfileComponent {
username = signal('guest');
constructor() {
// Effect runs whenever username() changes
effect(() => {
console.log(`Username changed to: ${this.username()}`);
// Sync to localStorage
localStorage.setItem('username', this.username());
});
}
}
Important Rules for Effects
- Injection context required: Effects must be created within an injection
context (constructor, field initializer, or wrapped in
runInInjectionContext) - No signal writes by default: Writing to signals inside an effect is
discouraged by default. If you need to, use the
allowSignalWritesoption - Automatic cleanup: Effects are automatically destroyed when their owning context (component, directive, or service) is destroyed
- Asynchronous scheduling: Effects do not run synchronously when a signal changes; they are batched and executed during change detection
Effect Cleanup and Manual Destruction
Effects support a cleanup function through the onCleanup callback, which runs
before each re-execution and when the effect is destroyed. This is essential for canceling
subscriptions, clearing timers, or aborting fetch requests.
import { signal, effect, DestroyRef, inject } from '@angular/core';
@Component({
selector: 'app-search',
standalone: true,
template: `<input (input)="onSearch($event)" />`
})
export class SearchComponent {
searchTerm = signal('');
constructor() {
effect((onCleanup) => {
const term = this.searchTerm();
if (!term) return;
const controller = new AbortController();
// Perform the search
fetch(`/api/search?q=${term}`, {
signal: controller.signal
}).then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// Cleanup: abort the previous request when term changes
onCleanup(() => controller.abort());
});
}
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}
Manual Destruction of Effects
import { signal, effect, EffectRef } from '@angular/core';
export class TimerComponent {
seconds = signal(0);
private timerEffect: EffectRef;
constructor() {
this.timerEffect = effect(() => {
console.log(`Timer: ${this.seconds()}s`);
});
}
stopLogging(): void {
// Manually destroy the effect - it will no longer run
this.timerEffect.destroy();
}
}
5. Signal Inputs and Outputs
Angular 17.1+ introduced signal-based inputs and function-based outputs,
replacing the traditional @Input() and @Output() decorators. These
new APIs are more type-safe, have better ergonomics, and integrate seamlessly with the signal
reactivity system.
Signal Inputs
import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<h3>{{ name() }}</h3>
<p>Role: {{ role() }}</p>
<p>Score: {{ score() }}</p>
</div>
`
})
export class UserCardComponent {
// Required input - parent MUST provide this
name = input.required<string>();
// Optional input with default value
role = input('viewer'); // InputSignal<string>, defaults to 'viewer'
// Optional input with explicit type
score = input<number>(0);
// Input with alias (for backwards compatibility)
// Usage: <app-user-card [userName]="..." />
name2 = input.required<string>({ alias: 'userName' });
// Input with transform function
disabled = input(false, {
transform: (value: boolean | string) =>
typeof value === 'string' ? value !== 'false' : value
});
}
Signal Inputs vs Decorator Inputs
| Feature | @Input() Decorator | input() Signal |
|---|---|---|
| Type safety | Requires manual typing | Fully inferred |
| Required inputs | No compile-time enforcement | Compile-time error if missing |
| Default values | Manually set | Built into the API |
| Reactivity | Needs ngOnChanges | Native signal (use in computed/effect) |
| Transform | Separate decorator option | Built-in transform option |
| Read-only | No (mutable by child) | Yes (InputSignal is read-only) |
Function-Based Outputs
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-todo-item',
standalone: true,
template: `
<div class="todo">
<span>{{ label() }}</span>
<button (click)="onComplete()">Complete</button>
<button (click)="onDelete()">Delete</button>
</div>
`
})
export class TodoItemComponent {
// Signal input
label = input.required<string>();
id = input.required<number>();
// Function-based outputs
completed = output<number>(); // OutputEmitterRef<number>
deleted = output<number>(); // OutputEmitterRef<number>
onComplete(): void {
this.completed.emit(this.id());
}
onDelete(): void {
this.deleted.emit(this.id());
}
}
// Parent usage:
// <app-todo-item
// [label]="todo.label"
// [id]="todo.id"
// (completed)="markComplete($event)"
// (deleted)="removeTodo($event)"
// />
6. Model Signals: Two-Way Binding Reimagined
The model() function creates a special signal that supports
two-way binding between parent and child components. It replaces the
traditional pattern of combining @Input() with @Output() and
the Change suffix convention.
import { Component, model, signal } from '@angular/core';
// Child component with model signal
@Component({
selector: 'app-rating',
standalone: true,
template: `
<div class="stars">
@for (star of stars(); track star) {
<span
[class.active]="star <= value()"
(click)="selectRating(star)">
★
</span>
}
</div>
`
})
export class RatingComponent {
// model() creates a writable signal with two-way binding
value = model(0); // ModelSignal<number>
maxStars = input(5); // InputSignal<number>
stars = computed(() =>
Array.from({ length: this.maxStars() }, (_, i) => i + 1)
);
selectRating(star: number): void {
this.value.set(star); // Updates the parent's bound value too
}
}
// Parent component
@Component({
selector: 'app-review',
standalone: true,
imports: [RatingComponent],
template: `
<h3>Your Rating: {{ rating() }}</h3>
<app-rating [(value)]="rating" />
<button (click)="rating.set(0)">Reset</button>
`
})
export class ReviewComponent {
rating = signal(3);
}
When to Use model() vs input() + output()
Use model() when the child component needs to both read and write a value
that the parent also cares about (e.g., form controls, toggles, ratings). Use separate
input() and output() when the child only emits events and the
parent decides how to handle them.
7. ViewChild and ViewChildren with Signals
Angular also provides signal-based replacements for the @ViewChild() and
@ViewChildren() decorators. The new viewChild() and
viewChildren() functions return signals that update automatically when the
referenced elements change.
import {
Component, viewChild, viewChildren,
ElementRef, signal, afterNextRender
} from '@angular/core';
@Component({
selector: 'app-gallery',
standalone: true,
template: `
<div #container class="gallery">
@for (image of images(); track image.id) {
<img #imageEl [src]="image.url" [alt]="image.title" />
}
</div>
<button (click)="scrollToTop()">Scroll to Top</button>
`
})
export class GalleryComponent {
images = signal([
{ id: 1, url: '/img/1.jpg', title: 'Photo 1' },
{ id: 2, url: '/img/2.jpg', title: 'Photo 2' },
{ id: 3, url: '/img/3.jpg', title: 'Photo 3' },
]);
// viewChild: returns Signal<ElementRef | undefined>
container = viewChild<ElementRef>('container');
// viewChild.required: returns Signal<ElementRef> (no undefined)
requiredContainer = viewChild.required<ElementRef>('container');
// viewChildren: returns Signal<readonly ElementRef[]>
imageElements = viewChildren<ElementRef>('imageEl');
constructor() {
afterNextRender(() => {
// Safe to access the DOM here
const el = this.container();
if (el) {
console.log('Container height:', el.nativeElement.offsetHeight);
}
// All image elements
console.log('Image count:', this.imageElements().length);
});
}
scrollToTop(): void {
this.container()?.nativeElement.scrollTo({ top: 0, behavior: 'smooth' });
}
}
Signal Queries vs Decorator Queries
| Feature | @ViewChild() Decorator | viewChild() Signal |
|---|---|---|
| Available in | ngAfterViewInit lifecycle | As a signal (reactive) |
| Reactivity | None (manual check needed) | Automatically updates |
| Use in computed/effect | Not possible | Fully supported |
| Required variant | No | Yes (viewChild.required()) |
| Type safety | Manual | Inferred |
8. Practical Patterns for Production
Now that we have covered the fundamentals, let us explore real-world patterns that you will use in production Angular applications.
Pattern 1: Async Data Loading
One of the most common patterns in any web application is loading data from an API. Signals combined with a dedicated state model make this clean and predictable.
import {
Component, signal, computed, inject, OnInit
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
@Component({
selector: 'app-product-list',
standalone: true,
template: `
@if (loading()) {
<div class="spinner">Loading...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else {
<input (input)="onFilter($event)" placeholder="Filter..." />
<p>Showing {{ filteredProducts().length }} of {{ products().length }}</p>
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<h4>{{ product.name }}</h4>
<p>{{ product.price | currency }}</p>
</div>
}
}
`
})
export class ProductListComponent implements OnInit {
private http = inject(HttpClient);
// State signals
products = signal<Product[]>([]);
loading = signal(true);
error = signal<string | null>(null);
filterTerm = signal('');
// Derived state
filteredProducts = computed(() => {
const term = this.filterTerm().toLowerCase();
if (!term) return this.products();
return this.products().filter(p =>
p.name.toLowerCase().includes(term) ||
p.category.toLowerCase().includes(term)
);
});
ngOnInit(): void {
this.loadProducts();
}
private loadProducts(): void {
this.loading.set(true);
this.error.set(null);
this.http.get<Product[]>('/api/products').subscribe({
next: (data) => {
this.products.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load products');
this.loading.set(false);
}
});
}
onFilter(event: Event): void {
const input = event.target as HTMLInputElement;
this.filterTerm.set(input.value);
}
}
Pattern 2: Shared State Service
For state shared across multiple components, create a service that encapsulates writable signals internally and exposes only read-only signals and methods.
import { Injectable, signal, computed } from '@angular/core';
interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
// Private writable state
private _items = signal<CartItem[]>([]);
// Public read-only projections
readonly items = this._items.asReadonly();
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly totalPrice = computed(() =>
this._items().reduce(
(sum, item) => sum + item.price * item.quantity, 0
)
);
readonly isEmpty = computed(() => this._items().length === 0);
addItem(product: Omit<CartItem, 'quantity'>): void {
this._items.update(items => {
const existing = items.find(i => i.productId === product.productId);
if (existing) {
return items.map(i =>
i.productId === product.productId
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { ...product, quantity: 1 }];
});
}
removeItem(productId: number): void {
this._items.update(items =>
items.filter(i => i.productId !== productId)
);
}
clearCart(): void {
this._items.set([]);
}
}
Pattern 3: Form State Management
Signals are excellent for managing form state, validation, and derived properties without the overhead of Reactive Forms for simple use cases.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-registration',
standalone: true,
template: `
<form (submit)="onSubmit($event)">
<div class="field">
<label>Email</label>
<input
[value]="email()"
(input)="email.set(toValue($event))"
[class.invalid]="emailError()"
/>
@if (emailError()) {
<span class="error">{{ emailError() }}</span>
}
</div>
<div class="field">
<label>Password</label>
<input
type="password"
[value]="password()"
(input)="password.set(toValue($event))"
[class.invalid]="passwordError()"
/>
@if (passwordError()) {
<span class="error">{{ passwordError() }}</span>
}
<div class="strength">Strength: {{ passwordStrength() }}</div>
</div>
<button [disabled]="!isValid()">Register</button>
</form>
`
})
export class RegistrationComponent {
// Form fields as signals
email = signal('');
password = signal('');
// Validation computed signals
emailError = computed(() => {
const val = this.email();
if (!val) return 'Email is required';
if (!val.includes('@')) return 'Invalid email format';
return null;
});
passwordError = computed(() => {
const val = this.password();
if (!val) return 'Password is required';
if (val.length < 8) return 'Password must be at least 8 characters';
return null;
});
passwordStrength = computed(() => {
const val = this.password();
if (val.length < 8) return 'Weak';
if (val.length < 12) return 'Medium';
return 'Strong';
});
isValid = computed(() =>
!this.emailError() && !this.passwordError()
);
toValue(event: Event): string {
return (event.target as HTMLInputElement).value;
}
onSubmit(event: Event): void {
event.preventDefault();
if (this.isValid()) {
console.log('Submitting:', {
email: this.email(),
password: this.password()
});
}
}
}
9. Migration from Zone.js: The Bridge
Most existing Angular applications use RxJS Observables extensively. Angular provides
two bridge functions in the @angular/core/rxjs-interop package to seamlessly
convert between Signals and Observables.
toSignal(): Observable to Signal
The toSignal() function subscribes to an Observable and returns a Signal
that always holds the most recent emitted value.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { interval } from 'rxjs';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<p>Server time: {{ serverTime() }}</p>
<p>Uptime: {{ uptime() }}s</p>
`
})
export class DashboardComponent {
private http = inject(HttpClient);
// Convert HTTP Observable to Signal
// Type: Signal<string | undefined> (undefined before first emission)
serverTime = toSignal(
this.http.get<string>('/api/time')
);
// Provide an initial value to avoid undefined
// Type: Signal<number>
uptime = toSignal(interval(1000), { initialValue: 0 });
// requireSync: for Observables that emit synchronously (like BehaviorSubject)
// Type: Signal<number> (no undefined)
// counter = toSignal(myBehaviorSubject$, { requireSync: true });
}
toSignal() Gotchas
- Injection context required:
toSignal()must be called in an injection context (constructor or field initializer) - Automatic unsubscription: The subscription is automatically cleaned up when the component is destroyed
- Default type is
T | undefined: If the Observable has not yet emitted, the signal value isundefined. UseinitialValueto provide a default - Error handling: If the Observable errors, the signal will throw the
same error when read. Consider using
catchErrorin the pipe
toObservable(): Signal to Observable
The toObservable() function creates an Observable that emits every time
a signal value changes. This is useful when you need to pipe signal values through
RxJS operators.
import { Component, signal, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-search-page',
standalone: true,
template: `
<input
[value]="query()"
(input)="query.set(toValue($event))"
placeholder="Search..."
/>
@for (result of results(); track result.id) {
<div>{{ result.title }}</div>
}
`
})
export class SearchPageComponent {
private http = inject(HttpClient);
query = signal('');
results = signal<any[]>([]);
// Convert signal to Observable for RxJS operators
private query$ = toObservable(this.query);
constructor() {
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.http.get<any[]>(`/api/search?q=${q}`))
).subscribe(results => this.results.set(results));
}
toValue(event: Event): string {
return (event.target as HTMLInputElement).value;
}
}
The resource() API
Angular 19+ introduced the resource() API, a signal-based approach to async
data fetching that combines the request trigger, loading state, and result into a single
reactive construct.
import { Component, signal, resource } from '@angular/core';
interface UserProfile {
id: number;
name: string;
email: string;
}
@Component({
selector: 'app-user-detail',
standalone: true,
template: `
@if (userResource.isLoading()) {
<div>Loading user...</div>
} @else if (userResource.error()) {
<div>Error loading user</div>
} @else {
<h2>{{ userResource.value()?.name }}</h2>
<p>{{ userResource.value()?.email }}</p>
}
<button (click)="nextUser()">Next User</button>
`
})
export class UserDetailComponent {
userId = signal(1);
// resource() automatically re-fetches when userId changes
userResource = resource({
request: () => ({ id: this.userId() }),
loader: async ({ request, abortSignal }) => {
const response = await fetch(
`/api/users/${request.id}`,
{ signal: abortSignal }
);
if (!response.ok) throw new Error('Failed to load user');
return response.json() as Promise<UserProfile>;
}
});
nextUser(): void {
this.userId.update(id => id + 1);
}
}
resource() Key Features
- Automatic refetch: When the signals in
requestchange, the loader re-executes - AbortSignal support: Previous requests are automatically aborted when a new one starts
- Built-in state:
.isLoading(),.value(),.error(),.status() - No injection context needed: Unlike
toSignal(), resource can be created anywhere
10. Best Practices and Common Pitfalls
Signals are powerful but, like any tool, they require discipline to use effectively. Here are the most important best practices and common mistakes to avoid.
Do: Keep Signal Granularity Appropriate
Use one signal per logical piece of state. Avoid creating a single "mega signal" that holds your entire application state, but also avoid creating too many fine-grained signals that become hard to manage.
// BAD: One giant signal for everything
const state = signal({
user: null,
products: [],
cart: [],
loading: false,
error: null,
filters: { category: '', minPrice: 0, maxPrice: 100 }
});
// Updating any field replaces the entire object,
// causing ALL dependents to re-evaluate
// GOOD: Separate signals per concern
const user = signal<User | null>(null);
const products = signal<Product[]>([]);
const cart = signal<CartItem[]>([]);
const loading = signal(false);
const error = signal<string | null>(null);
const filterCategory = signal('');
const filterMinPrice = signal(0);
const filterMaxPrice = signal(100);
// Only dependents of the changed signal re-evaluate
Do: Use computed() for Derived State
Never manually synchronize signals when one value can be derived from another. Always use
computed() for derived state.
// BAD: Manually synchronizing
const items = signal<Item[]>([]);
const count = signal(0); // manually kept in sync
function addItem(item: Item): void {
items.update(list => [...list, item]);
count.set(items().length); // easy to forget!
}
// GOOD: Use computed for derived values
const items = signal<Item[]>([]);
const count = computed(() => items().length); // always correct
Common Pitfalls to Avoid
-
Do not call signals in event handlers without reading them:
Writing
mySignal(without parentheses) passes the signal function itself, not its value. Always usemySignal()to read. -
Do not modify objects/arrays in place:
Signals use equality checks. Mutating an object and calling
.set()with the same reference will not trigger updates. Always create new references. -
Do not use effects for derived state:
If you find yourself writing to a signal inside an
effect(), you probably need acomputed()instead. -
Do not nest signals unnecessarily:
A
signal(signal(0))creates a signal of a signal, which is almost never what you want. -
Watch out for late subscribers:
Unlike BehaviorSubject, effects created after a signal has been set will still see the current
value, but
toObservable()only emits changes after subscription.
Do: Use the untracked() Function When Needed
Sometimes inside a computed() or effect(), you want to read a
signal's value without creating a dependency on it. The untracked() function
lets you do exactly that.
import { signal, computed, effect, untracked } from '@angular/core';
const firstName = signal('John');
const lastName = signal('Doe');
const logCount = signal(0);
// Only re-compute when firstName changes, not lastName
const greeting = computed(() => {
const first = firstName();
const last = untracked(() => lastName()); // read but don't track
return `Hello, ${first} ${last}`;
});
// Effect that logs changes but doesn't re-run when logCount changes
effect(() => {
const name = firstName();
const count = untracked(() => logCount());
console.log(`[${count}] Name is: ${name}`);
});
Performance Tip: linkedSignal()
Angular 19+ also introduced linkedSignal(), which creates a writable signal
that automatically resets whenever its source signal changes. This is useful for dependent
UI state like pagination that resets when filters change.
import { signal, linkedSignal, computed } from '@angular/core';
const selectedCategory = signal('all');
const products = signal<Product[]>([]);
// currentPage resets to 0 whenever selectedCategory changes
const currentPage = linkedSignal(() => {
selectedCategory(); // track the source
return 0; // reset value
});
// But it is still writable for user navigation
function nextPage(): void {
currentPage.update(p => p + 1);
}
// When the category changes, currentPage automatically goes back to 0
selectedCategory.set('electronics');
console.log(currentPage()); // 0
Summary and What Comes Next
In this article, we have covered the entire Signals API surface in Angular:
- signal() for writable state with
.set(),.update(), and.asReadonly() - computed() for lazy, memoized derived state with automatic dependency tracking
- effect() for side effects with cleanup functions and automatic disposal
- input() and output() for type-safe, signal-based component communication
- model() for two-way binding without boilerplate
- viewChild() and viewChildren() for reactive DOM queries
- toSignal() and toObservable() for bridging between Signals and RxJS
- resource() for signal-based async data loading
- linkedSignal() for resettable dependent state
- untracked() for reading signals without dependency tracking
Quick Reference
| Function | Purpose | Writable | Requires Injection Context |
|---|---|---|---|
signal() | Create writable state | Yes | No |
computed() | Derive values | No | No |
effect() | Run side effects | N/A | Yes |
input() | Component input | No | No |
output() | Component output | N/A | No |
model() | Two-way binding | Yes | No |
viewChild() | Query single element | No | No |
viewChildren() | Query multiple elements | No | No |
toSignal() | Observable to Signal | No | Yes |
toObservable() | Signal to Observable | N/A | Yes |
resource() | Async data loading | No | No |
linkedSignal() | Resettable derived state | Yes | No |
untracked() | Read without tracking | N/A | No |
Signals are not just a new API; they are the foundation of Angular's future. Every new feature in Angular (zoneless change detection, incremental hydration, signal forms) is built on top of the signal primitives. Understanding them deeply is essential for any modern Angular developer.
In the next article of this series, we will explore Zoneless Change
Detection: how to remove Zone.js entirely from your application, configure
provideExperimentalZonelessChangeDetection(), and understand the implications
for existing applications.
Related deep dive: 2 - Zoneless Change Detection: addio Zone.js.
Related deep dive: 3 - I Nuovi Template Angular: @if, @for, @defer.
Frequently asked questions
What is the main problem that Signals in Angular solve?
Signals solve the problem of inefficient change detection and reactivity in Angular applications by introducing a push-based, fine-grained reactivity system.
How do signals determine whether a value has actually changed for complex objects?
By default, signals use structural equality to determine whether a value has actually changed. However, you can provide a custom equality function for complex objects.
What is the primary mechanism for building derived state in Angular using Signals?
Computed signals are the primary mechanism for building derived state in Angular, as they automatically derive their value from other signals and are lazy, memoized, and read-only.
How do you convert an Observable to a Signal in Angular?
You can use the `toSignal()` function to subscribe to an Observable and return a Signal that always holds the most recent emitted value.
Related deep dive: OnPush e Signals come Default, non Eccezione: 1215 Componenti in un Monorepo Angular 21.
Related deep dive: Strict Mode non Basta: 100 Breach di Tipo in un Monorepo TypeScript al 100% Strict.
Commenti
Caricamento commenti...
Sign in to comment