Mastering Angular Signals: The Ultimate Guide to Modern Reactivity

For years, Angular developers have relied on Zone.js and RxJS to manage state and handle reactivity. While powerful, these tools often come with a steep learning curve and performance overhead. Have you ever been frustrated by the infamous ExpressionChangedAfterItHasBeenCheckedError? Or perhaps you’ve struggled with the boilerplate required to manage a simple counter using an RxJS BehaviorSubject?

Angular Signals represent the most significant shift in the framework’s history since its inception. Introduced as a developer preview in Angular 16 and stabilized in subsequent versions, Signals provide a granular way to track state changes. This means Angular can now pinpoint exactly which parts of the UI need to update, without checking the entire component tree.

In this comprehensive guide, we will dive deep into Angular Signals. Whether you are a beginner looking to write your first signal or an intermediate developer aiming to optimize your application’s performance, this post will provide everything you need to know to master modern Angular reactivity.

What are Angular Signals?

At its core, a Signal is a wrapper around a value that notifies interested consumers when that value changes. Think of it like a cell in an Excel spreadsheet. If cell A1 depends on B1, and you change B1, A1 updates automatically. Signals bring this “push-pull” reactive model to Angular core.

Signals differ from traditional variables because they are reactive. When you use a Signal in a template, Angular creates a dependency. When the Signal’s value changes, Angular knows exactly which template expression needs to be re-evaluated.

The Three Pillars of Signals

  • Writable Signals: Values that you can update directly.
  • Computed Signals: Values derived from other signals (read-only).
  • Effects: Functions that run whenever the signals they depend on change (used for side effects).

Why Do We Need Signals? The Problem with Zone.js

To understand why Signals are a game-changer, we must look at how Angular traditionally handles change detection. Angular uses a library called Zone.js. Zone.js “monkeys-patches” browser APIs (like clicks, timers, and HTTP requests) to notify Angular that “something happened.”

When “something happens,” Angular performs change detection by checking every component from the top down to see if any data has changed. In large applications, this can be incredibly expensive. Even if a tiny piece of text in a footer changes, Angular might check hundreds of components.

Signals solve this by being “Signal-aware.” Instead of the framework guessing what changed, the Signal tells the framework exactly what changed. This paves the way for Zoneless Angular, leading to faster startup times, smaller bundle sizes, and significantly better runtime performance.

Getting Started: Your First Writable Signal

Let’s start with the basics. To create a signal, you use the signal() function imported from @angular/core.


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 {
  // Initialize a signal with a value of 0
  count = signal(0);

  increment() {
    // Update the signal value based on its previous state
    this.count.update(value => value + 1);
  }

  reset() {
    // Set the signal to a specific value
    this.count.set(0);
  }
}

How it works:

  • count(): Note the parentheses! Signals are functions. Calling them returns the current value.
  • .set(value): Replaces the signal’s value with a new one.
  • .update(fn): Computes a new value based on the previous value.

Computed Signals: Deriving State Effortlessly

Often, you need a value that depends on other values. For example, if you have a list of products and a search filter, the filtered list is “derived” state. In the past, you might have used a getter or an RxJS map operator.

With computed(), Angular creates a read-only signal that automatically stays in sync.


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

@Component({
  selector: 'app-shopping-cart',
  standalone: true,
  template: `
    <p>Price: {{ price() }}</p>
    <p>Quantity: {{ quantity() }}</p>
    <h2>Total Cost: {{ totalCost() }}</h2>
  `
})
export class ShoppingCartComponent {
  price = signal(100);
  quantity = signal(2);

  // totalCost will automatically update whenever price or quantity changes
  totalCost = computed(() => this.price() * this.quantity());
}

Pro-tip: Computed signals are lazily evaluated and memoized. This means the calculation only happens the first time you read the value or after a dependency has changed. This is much more efficient than using a method call in your template.

Effects: Handling Side Effects

Sometimes you need to run code when a signal changes, but not to produce a new value. Examples include logging, saving to localStorage, or triggering manual DOM manipulations. For this, we use effect().


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

@Component({ ... })
export class LoggerComponent {
  count = signal(0);

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

Important Note: Effects must be created within an “injection context” (like a constructor) unless you manually pass an Injector.

Angular Signals vs. RxJS: Which One to Use?

This is the most common question among Angular developers today. Are Signals replacing RxJS? The short answer is No.

Think of them as tools for different jobs:

  • Signals: Best for State Management. They are synchronous, easy to read, and perfect for UI state.
  • RxJS: Best for Asynchronous Events. Use RxJS for HTTP requests, web sockets, debouncing user input, and complex data streams.

The good news? They work together beautifully using the @angular/core/rxjs-interop package.


import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';

export class UserProfile {
  private http = inject(HttpClient);
  
  // Convert an Observable to a Signal
  users$ = this.http.get<User[]>('https://api.example.com/users');
  users = toSignal(this.users$, { initialValue: [] });
}

Step-by-Step: Building a Reactive Filter with Signals

Let’s put everything together. We will build a simple search interface that filters a list of names in real-time.

Step 1: Define the Data

Create your signals for the raw data and the search query.


const NAMES = ['Alice', 'Bob', 'Charlie', 'David', 'Eve'];
const searchTerm = signal('');

Step 2: Create the Computed Filter

This will derive the filtered list whenever searchTerm changes.


const filteredNames = computed(() => {
  return NAMES.filter(name => 
    name.toLowerCase().includes(searchTerm().toLowerCase())
  );
});

Step 3: Bind to the Template

Use standard event binding to update the signal.


<input 
  [value]="searchTerm()" 
  (input)="searchTerm.set($any($event.target).value)" 
  placeholder="Search names..." 
/>

<ul>
  <li *ngFor="let name of filteredNames()">{{ name }}</li>
</ul>

Common Mistakes and How to Fix Them

1. Forgetting the Parentheses

Since Signals are functions, you must call them to get the value. Writing {{ count }} in a template instead of {{ count() }} will render the function definition rather than the value.

Fix: Always use () when accessing a signal value.

2. Modifying Signals inside an Effect

By default, Angular prevents you from writing to a signal inside an effect() to avoid infinite loops. While you can bypass this with allowSignalWrites: true, it is usually a sign of poor architectural design.

Fix: Use computed() to derive new state instead of updating a signal inside an effect.

3. Overusing Signals for Everything

Not every variable needs to be a signal. If a value never changes or doesn’t need to be reflected in the UI, a standard class property is fine. Signals introduce a small memory overhead; use them where reactivity is required.

Advanced Patterns: Signal-Based Services

Signals are perfect for the “Service with a Subject” pattern, but without the complexity of RxJS. Here is how you can build a global store.


@Injectable({ providedIn: 'root' })
export class ThemeService {
  // Private writable signal
  private _darkMode = signal(false);

  // Public read-only signal
  darkMode = this._darkMode.asReadonly();

  toggleTheme() {
    this._darkMode.update(v => !v);
  }
}

By using asReadonly(), you ensure that components can’t accidentally change the state directly—they must go through the service’s methods.

Summary and Key Takeaways

Angular Signals are a transformative feature that makes the framework more intuitive and performant. Here are the key points to remember:

  • Signals provide fine-grained reactivity, allowing Angular to update only the parts of the DOM that changed.
  • Use Writable Signals for state you can change, Computed for derived state, and Effects for side effects.
  • Signals are synchronous and glitch-free, ensuring that derived data is always consistent.
  • They do not replace RxJS but complement it. Use Signals for state and RxJS for asynchronous streams.
  • Signals are the key to a Zoneless Angular future, offering significant performance gains.

Frequently Asked Questions (FAQ)

1. Are Signals faster than RxJS?

For state management and template updates, yes. Signals are optimized for the synchronous tracking of dependencies within the Angular framework, whereas RxJS is a general-purpose asynchronous library with more overhead.

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

No, Signals were officially introduced in Angular 16. To use them, you must upgrade your project to at least version 16, though version 17+ is recommended for the most stable experience.

3. Do I still need to use ChangeDetectorRef?

In most cases, no. When you use Signals in your templates, Angular handles the update logic automatically. As we move toward Zoneless applications, manual change detection will become largely unnecessary.

4. Is it okay to put an object inside a signal?

Yes, you can store objects in signals. However, keep in mind that Angular checks for equality using ===. If you mutate a property inside an object, the signal won’t realize it changed. It is better to use immutable patterns: user.update(u => ({ ...u, name: 'New Name' })).

5. Will Signals work with OnPush change detection?

Absolutely! Signals work perfectly with ChangeDetectionStrategy.OnPush. In fact, Signals make OnPush even more powerful by telling Angular exactly when a component needs to be marked as dirty.