Mastering Angular Signals: The Ultimate Guide to Fine-Grained Reactivity

Introduction: The Evolution of Angular Reactivity

For years, Angular developers have relied on Zone.js and the standard change detection mechanism to keep the user interface in sync with the application state. While powerful, this “pull-based” approach often leads to performance bottlenecks in large-scale applications. Whenever an event occurs—be it a click, a timer, or an HTTP request—Angular checks the entire component tree to see what has changed. This is where Angular Signals come in.

Introduced in Angular 16 and refined in subsequent versions, Signals represent the most significant shift in the Angular ecosystem since its inception. They introduce a “push-based,” fine-grained reactivity model that allows the framework to know exactly which part of the UI needs updating, without checking the whole world. If you want to build blazing-fast, scalable, and modern web applications, understanding Signals is no longer optional—it is essential.

In this comprehensive guide, we will dive deep into the world of Signals. We will explore why they matter, how they work under the hood, and how you can implement them in your projects today to achieve superior performance and cleaner code.

The Problem: Why Do We Need Signals?

To understand the “why” behind Signals, we must first look at the limitations of the traditional Change Detection cycle. Angular currently uses a library called Zone.js. Zone.js monkeys-patches browser APIs (like setTimeout or fetch) to notify Angular whenever an asynchronous task finishes. Once notified, Angular runs change detection from the root component down to the leaves.

The Overhead of Zone.js

While this “magic” makes development easy for beginners, it carries several downsides:

  • Performance: In complex apps with hundreds of components, checking every single one on every click is computationally expensive.
  • Bundle Size: Zone.js adds roughly 13kb (gzipped) to your initial bundle.
  • Debugging: Stack traces involving Zone.js can be notoriously difficult to read.
  • Developer Experience: Developers often have to use ChangeDetectionStrategy.OnPush manually to optimize performance, which requires a deep understanding of object references and RxJS.

Signals solve these issues by providing a reactive primitive. Instead of Angular guessing what changed, the Signal itself tells the framework, “Hey, my value changed, and only these specific templates using me need to re-render.” This is what we call fine-grained reactivity.

What 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 as a variable that has “superpowers.” It doesn’t just hold data; it manages the dependency graph of your application logic.

Signals are characterized by three main concepts:

  1. Getter: You call the signal as a function to get its value (e.g., mySignal()).
  2. Producer: The signal holds the state.
  3. Consumer: Any code that reads the signal becomes a consumer and is notified of updates.

Unlike RxJS Observables, Signals are synchronous. There is no need to subscribe, no need to unsubscribe to prevent memory leaks in templates, and they always have an initial value.

1. Writable Signals: Creating and Modifying State

The most basic type of signal is the WritableSignal. This is where you store data that you intend to change over time, such as a user’s name, a counter, or an array of items.

How to Create a Writable Signal


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

// Initializing a signal with a default value of 0
const count = signal(0);

// Reading the value
console.log(count()); // Output: 0
            

Updating Writable Signals

There are two primary ways to update a Writable Signal: .set() and .update().

Using .set()

Use set() when you want to replace the value with a completely new one, regardless of the previous state.


count.set(10); // The count is now 10
            

Using .update()

Use update() when the new value depends on the previous value. This is perfect for counters or toggles.


// The parameter 'val' represents the current value
count.update(val => val + 1); 
            

Working with Objects and Arrays

When working with objects, you should follow immutability patterns. Angular uses reference checks to determine if a signal has changed. If you mutate an object property directly without changing the reference, the signal might not trigger an update.


const user = signal({ id: 1, name: 'John' });

// Correct way: Create a new object reference
user.set({ ...user(), name: 'Jane' });
            

2. Computed Signals: Derived State

Often, you need a value that is calculated based on other signals. For example, if you have a price signal and a quantity signal, you want a total value that updates automatically. This is what computed() is for.


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

const price = signal(100);
const quantity = signal(2);

// Computed signal: recalculates whenever price or quantity changes
const total = computed(() => price() * quantity());

console.log(total()); // Output: 200

price.set(150);
console.log(total()); // Output: 300
            

Key Features of Computed Signals

  • Read-only: You cannot call .set() on a computed signal. Its value is derived solely from its dependencies.
  • Lazy Evaluation: Computed signals are only calculated when they are actually read. If nobody is listening to total(), the multiplication logic never runs.
  • Memoization: Angular caches the result. If the underlying dependencies (price or quantity) don’t change, repeated calls to total() return the cached value instantly.
  • Dynamic Dependency Tracking: Angular automatically tracks which signals are used inside the computed function. If you have an if statement, and a signal is only read in one branch, Angular only tracks it when that branch is active.

3. Effects: Handling Side Effects

Sometimes, you need to run code when a signal changes, but that code isn’t about calculating a new value. You might want to log data to the console, save data to localStorage, or perform a manual DOM manipulation. For these scenarios, use effect().


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

const count = signal(0);

effect(() => {
  console.log(`The current count is: ${count()}`);
  // This runs initially and then every time 'count' changes.
});
            

Rules for Using Effects

  • Injection Context: Effects must be created inside an “injection context” (like a component constructor or as a class field). Otherwise, you must manually provide an Injector.
  • No Signal Mutations: By default, you cannot update signals inside an effect(). This prevents infinite loops. If absolutely necessary, you can enable allowSignalWrites, but it is generally discouraged.
  • Automatic Cleanup: Effects provide an onCleanup function to handle tasks like clearing timers or cancelling network requests.

effect((onCleanup) => {
  const timer = setTimeout(() => {
    console.log('User has been idle for 5 seconds');
  }, 5000);

  onCleanup(() => {
    clearTimeout(timer); // Cleans up if the signal changes or component is destroyed
  });
});
            

Step-by-Step: Building a Reactive Shopping Cart

Let’s put everything together with a real-world example: A shopping cart where items can be added, and the total is calculated automatically.

Step 1: Define the Interface


interface Product {
  id: number;
  name: string;
  price: number;
}
            

Step 2: Create the Service

We will use a service to manage the cart state using signals.


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

@Injectable({ providedIn: 'root' })
export class CartService {
  // Writable signal for the list of items
  private cartItems = signal<Product[]>([]);

  // Computed signal for the total price
  totalPrice = computed(() => 
    this.cartItems().reduce((acc, curr) => acc + curr.price, 0)
  );

  // Computed signal for the item count
  itemCount = computed(() => this.cartItems().length);

  // Expose items as a read-only signal
  items = this.cartItems.asReadonly();

  addToCart(product: Product) {
    this.cartItems.update(prev => [...prev, product]);
  }

  removeItem(productId: number) {
    this.cartItems.update(prev => prev.filter(p => p.id !== productId));
  }
}
            

Step 3: The Component Template

In the template, we call the signals as functions. Angular is smart enough to link these calls to the change detection cycle.


<!-- cart.component.html -->
<div>
  <h2>Shopping Cart ({{ cartService.itemCount() }} items)</h2>
  
  <ul>
    <li *ngFor="let item of cartService.items()">
      {{ item.name }} - ${{ item.price }}
      <button (click)="cartService.removeItem(item.id)">Remove</button>
    </li>
  </ul>

  <hr>
  <strong>Total: ${{ cartService.totalPrice() }}</strong>
</div>
            

Signals vs. RxJS: When to Use Which?

One of the most common questions is: “Does this replace RxJS?” The answer is No. Signals and RxJS serve different purposes, and they actually work great together.

Feature Signals RxJS (Observables)
Primary Goal State Management / UI Sync Asynchronous Streams / Events
Value Over Time Always has a current value Stream of multiple values
Timing Synchronous Mostly Asynchronous
Complexity Simple, low learning curve Steep learning curve (Operators)

Use Signals for: Component state, derived data, template variables, and simple inter-component communication.

Use RxJS for: HTTP requests, web sockets, handling rapid user input (debounce/throttle), and complex event orchestration.

Interoperability

Angular provides utilities to convert between the two in the @angular/core/rxjs-interop package:

  • toSignal(observable$): Converts an Observable into a Signal.
  • toObservable(mySignal): Converts a Signal into an Observable.

Common Mistakes and How to Fix Them

1. Forgetting the Parentheses

Because signals are functions, you must call them to get the value. Writing {{ count }} in your template will display the function definition instead of the number. Always use {{ count() }}.

2. Mutating State Directly

If you have a signal holding an array, doing mySignal().push(item) will mutate the internal array but Angular won’t see a new reference, so it might not update the UI. Always use immutable patterns: mySignal.set([...mySignal(), item]).

3. Placing Effects in the Wrong Place

Creating an effect() inside a method that gets called multiple times will create multiple redundant effects, leading to memory leaks and performance degradation. Always define effects in the constructor or as a class property.

4. Circular Dependencies

If Signal A updates in an effect that Signal B reads, and Signal B updates Signal A, you’ll create an infinite loop. Angular has built-in protections for this, but it’s still a logic error you should avoid by keeping your data flow unidirectional.

Advanced Concepts: Signal Equality and Untracked

Signal Equality Functions

By default, signals use strict equality (===) to check if a value has changed. You can provide a custom equality function if you want more control, for example, to perform a deep comparison on objects.


const mySignal = signal({ id: 1 }, {
  equal: (a, b) => JSON.stringify(a) === JSON.stringify(b)
});
            

The ‘untracked’ Function

Sometimes you want to read a signal’s value inside a computed or effect WITHOUT creating a dependency on it. You use untracked() for this.


import { untracked, effect } from '@angular/core';

effect(() => {
  const currentCount = count();
  const currentUser = untracked(user); // Read user, but don't re-run effect when user changes
  console.log(`Count is ${currentCount}, Logged in as ${currentUser.name}`);
});
            

Summary and Key Takeaways

Angular Signals are a revolutionary addition to the framework that simplify state management and improve performance. Here are the highlights:

  • Signals provide fine-grained reactivity, reducing the need for Zone.js and full-tree change detection.
  • Writable Signals are for state you can change.
  • Computed Signals are for derived, read-only state with lazy evaluation and memoization.
  • Effects are for side effects like logging or external API calls.
  • Signals do not replace RxJS; they complement it by handling synchronous state while RxJS handles asynchronous streams.
  • To get the best performance, aim for Zoneless applications by fully embracing Signal-based components.

Frequently Asked Questions (FAQ)

1. Are Signals faster than RxJS in templates?

Yes, for UI updates. Signals are specifically optimized for the Angular rendering engine. While RxJS with the async pipe is efficient, Signals allow Angular to perform targeted updates to specific DOM nodes, which is even faster.

2. Can I use Signals in Angular 15 or older?

No, Signals were introduced as a developer preview in Angular 16. To use them effectively with all features and stability, it is recommended to use Angular 17 or 18.

3. Do Signals cause memory leaks?

Generally, no. Signals created within components are automatically cleaned up when the component is destroyed. Effects are also tied to the cleanup of the context they were created in.

4. Should I stop using the ‘async’ pipe?

Not necessarily. The async pipe is still great for Observables coming from services like HttpClient. However, for internal component state, converting those Observables to Signals using toSignal() is the modern best practice.

5. Can I use Signals with NgRx?

Yes! NgRx has introduced @ngrx/signals, a dedicated library that uses Angular Signals for state management, providing a lightweight and functional alternative to the traditional Store/Actions/Reducers pattern.

Mastering Angular Signals is the first step toward building the next generation of high-performance web applications. Start small, replace your component variables with signals, and watch your application’s responsiveness soar.