For years, Angular developers have relied on a combination of Zone.js and RxJS to handle reactivity. While powerful, this duo often felt like a “black box” for beginners and a performance bottleneck for experts. Zone.js tracks every click, timer, and HTTP request, triggering a global change detection cycle that scans the entire component tree even when only a single pixel needs to change.
Enter Angular Signals. Introduced as a developer preview in Angular 16 and stabilized in Angular 17/18, Signals represent the most significant shift in the framework’s history. They offer a granular, “fine-grained” way to track state changes, allowing Angular to update only the specific parts of the DOM that actually changed. This leads to faster apps, simpler code, and a more predictable development experience.
In this comprehensive guide, we will dive deep into the world of Angular Signals. Whether you are a beginner looking to understand the basics or an intermediate developer planning to migrate a legacy app, this post covers everything you need to know to master this reactive revolution.
What Exactly Are Angular Signals?
At its core, a Signal is a wrapper around a value that can notify interested consumers when that value changes. Think of it like a cell in an Excel spreadsheet. If cell A1 contains a number and cell B1 has a formula =A1 + 10, whenever you change A1, B1 updates automatically. The spreadsheet “knows” the relationship between the cells. Signals bring this same “push-based” reactivity to Angular.
Before Signals, if a variable changed in your component, Angular would run change detection across the whole tree. With Signals, Angular knows exactly which component—and even which part of the template—depends on that specific Signal. This is what we call fine-grained reactivity.
The Three Pillars of Signals
- Writable Signals: Variables you can update directly.
- Computed Signals: Values derived from other Signals (read-only).
- Effects: Side effects that run whenever the Signals they depend on change.
1. Getting Started with Writable Signals
A Writable Signal is the most basic building block. It holds a value and provides methods to change it. Unlike standard variables, you access the value by calling the signal as a function.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div>
<h1>Count: {{ count() }}</h1>
<button (click)="increment()">Increment</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// 1. Initialize the signal with a value of 0
count = signal(0);
increment() {
// 2. Update the signal based on its current value
this.count.update(value => value + 1);
}
reset() {
// 3. Set the signal to a specific value
this.count.set(0);
}
}
Key Methods for Writable Signals:
- set(newValue): Replaces the current value with a brand-new one.
- update(fn): Updates the value based on the previous state (perfect for counters or toggles).
- asReadonly(): Returns a read-only version of the signal, which is useful for encapsulating state in services.
2. Deriving State with Computed Signals
Often, you need a value that depends on other values. For example, a “Full Name” depends on “First Name” and “Last Name,” or a “Filtered List” depends on a “Search Term” and the “Full List.”
In traditional Angular, you might use a getter or a method in the template. However, methods in templates run on every change detection cycle, causing performance issues. Computed signals solve this by being memoized—they only recalculate if their dependencies change.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-shopping-cart',
template: `
<p>Price: {{ price() }}</p>
<p>Quantity: {{ quantity() }}</p>
<h2>Total Cost: {{ totalCost() }}</h2>
`
})
export class ShoppingCartComponent {
price = signal(100);
quantity = signal(2);
// This signal updates only when price OR quantity changes
totalCost = computed(() => this.price() * this.quantity());
}
Why is this better? If some other variable in your component changes (e.g., a “User Profile Name”), the totalCost will not be recalculated. Angular remembers the last value of the computed signal and simply returns it. This makes your application incredibly efficient.
3. Managing Side Effects with effects()
Sometimes you need to run code when a signal changes, but that code isn’t about returning a new value. This is called a “side effect.” Common examples include logging to the console, saving data to LocalStorage, or triggering a manual DOM animation.
import { Component, signal, effect } from '@angular/core';
@Component({ ... })
export class LoggerComponent {
theme = signal('light');
constructor() {
// This effect runs immediately and every time 'theme' changes
effect(() => {
console.log(`The current theme is: ${this.theme()}`);
localStorage.setItem('user-theme', this.theme());
});
}
toggleTheme() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}
Important Note: Effects should be used sparingly. You should never use an effect to update another signal. If you find yourself doing that, you should probably be using a computed signal instead.
Step-by-Step Instructions: Building a Signal-Based Task Manager
Let’s put everything together. We will build a small task manager that demonstrates how Signals handle state, computed filtering, and side effects.
Step 1: Define the Task Interface
Create a simple interface to represent our tasks.
export interface Task {
id: number;
title: string;
completed: boolean;
}
Step 2: Create the Component and Signals
We will use a writable signal for the list of tasks and another for the current filter (All, Active, Completed).
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-task-manager',
standalone: true,
templateUrl: './task-manager.component.html'
})
export class TaskManagerComponent {
// The source of truth
tasks = signal<Task[]>([
{ id: 1, title: 'Learn Signals', completed: false },
{ id: 2, title: 'Write Blog Post', completed: true }
]);
filter = signal<'all' | 'active' | 'completed'>('all');
// Derived state: The filtered list
filteredTasks = computed(() => {
const currentTasks = this.tasks();
const currentFilter = this.filter();
if (currentFilter === 'active') return currentTasks.filter(t => !t.completed);
if (currentFilter === 'completed') return currentTasks.filter(t => t.completed);
return currentTasks;
});
// Derived state: Task counts
remainingCount = computed(() => this.tasks().filter(t => !t.completed).length);
constructor() {
// Side effect: Save to storage
effect(() => {
localStorage.setItem('tasks', JSON.stringify(this.tasks()));
});
}
addTask(title: string) {
const newTask: Task = { id: Date.now(), title, completed: false };
this.tasks.update(allTasks => [...allTasks, newTask]);
}
toggleTask(id: number) {
this.tasks.update(allTasks =>
allTasks.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}
}
Step 3: The HTML Template
In the template, we call the signals as functions: filteredTasks().
<div>
<h2>My Tasks ({{ remainingCount() }} left)</h2>
<div>
<button (click)="filter.set('all')">All</button>
<button (click)="filter.set('active')">Active</button>
<button (click)="filter.set('completed')">Completed</button>
</div>
<ul>
@for (task of filteredTasks(); track task.id) {
<li>
<input type="checkbox" [checked]="task.completed" (change)="toggleTask(task.id)">
{{ task.title }}
</li>
}
</ul>
</div>
Angular Signals vs. RxJS: Which One to Choose?
This is the most common question among developers. Should you delete RxJS? The answer is No. Signals and RxJS serve different purposes and actually work great together.
Use Signals when:
- You are managing Synchronous State (e.g., UI toggles, form values, local component data).
- You want simple, readable code without the overhead of operators like
map,filter, orcombineLatest. - You want to improve rendering performance.
Use RxJS when:
- You are dealing with Asynchronous Streams (e.g., WebSockets, interval timers, complex HTTP sequences).
- You need powerful orchestration (e.g.,
switchMapfor search-as-you-type,retryfor API calls). - You need event-based handling that involves time (e.g., debounce, throttle).
Bridging the Gap: toSignal and toObservable
Angular provides utilities to convert between the two. This is vital for taking an HTTP request (Observable) and displaying it in a Signal-based template.
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
export class UserListComponent {
private http = inject(HttpClient);
// Converts an Observable into a Signal
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}
Advanced Performance: The End of Zone.js?
One of the primary goals of Signals is to enable “Zone-less” Angular. Historically, Angular relied on Zone.js to monkey-patch browser APIs and tell Angular “Something might have changed; check everything.”
With Signals, change detection can be Local. If a Signal changes in Component A, Angular only checks Component A. It doesn’t need to traverse the whole application. This results in massive performance gains for large-scale enterprise applications. In future versions, you will be able to bootstrap an Angular app without Zone.js entirely by opting into Signal-based components.
Common Mistakes and How to Avoid Them
Mistake 1: Forgetting the Parentheses
Signals are getter functions. If you write {{ count }} in your template instead of {{ count() }}, you won’t see the value. You’ll likely see a string representation of the function itself.
The Fix: Always use () when reading a Signal.
Mistake 2: Mutating Objects Inside a Signal
Signals rely on immutability or specific update patterns to trigger changes. If you have a signal holding an array and you use this.mySignal().push(item), the signal might not notify consumers because the reference to the array hasn’t changed.
The Fix: Use the update() method and return a new array or object using the spread operator: this.mySignal.update(arr => [...arr, newItem]).
Mistake 3: Infinite Loops in Effects
Writing to a Signal inside an effect() that reads that same Signal will cause an infinite loop. Angular actually prevents this by default and will throw an error.
The Fix: Use computed() if you need to derive a value, or use untracked() if you must read a value without creating a dependency.
Why Signals Matter for SEO and User Experience
From an SEO perspective, Google and Bing prioritize sites with excellent “Core Web Vitals.” One of these metrics is Interaction to Next Paint (INP). Large Angular apps often struggle with INP because Zone.js runs heavy change detection cycles that block the main thread.
By using Signals, you reduce the time the CPU spends calculating what changed. This makes your buttons feel snappier and your scrolls smoother. A faster site leads to higher search engine rankings, lower bounce rates, and better conversion rates.
Summary / Key Takeaways
- Fine-Grained Reactivity: Signals update only what is necessary, bypassing the “check everything” approach of Zone.js.
- Writable Signals: Create them with
signal(initialValue)and update with.set()or.update(). - Computed Signals: Use them for any value that depends on other signals. They are memoized and efficient.
- Effects: Use
effect()for logging, storage, or manual DOM changes, but avoid using them to set state. - Hybrid Approach: Use RxJS for complex async logic and Signals for UI state. Convert between them using
toSignalandtoObservable. - Future-Proofing: Learning Signals now prepares you for the upcoming Zone-less era of Angular.
Frequently Asked Questions (FAQ)
1. Are Signals replacing RxJS in Angular?
No. Signals are designed for synchronous state management, while RxJS remains the best tool for asynchronous streams and complex event handling. They are complementary, not competitors.
2. Can I use Signals in older versions of Angular?
Signals were introduced in Angular 16. To use them in a stable environment, it is highly recommended to upgrade to at least Angular 17 or the latest version (Angular 18+).
3. Do Signals work with OnPush change detection?
Yes! In fact, Signals work perfectly with ChangeDetectionStrategy.OnPush. When a Signal inside an OnPush component updates, Angular automatically marks that component and its ancestors for check.
4. Can I put an entire object in a Signal?
Yes, you can. However, to trigger an update, you must provide a new object reference. Example: user.set({ ...user(), name: 'New Name' }).
5. How do I debug Signals?
The Angular DevTools browser extension has been updated to support Signals, allowing you to inspect the dependency graph and see how values are flowing through your application.
