Angular Signals: The Complete Developer's Guide
angular frontend ui/ux

Angular Signals: The Complete Developer's Guide

D. Rout

D. Rout

February 21, 2026 8 min read

On this page

Audience: Angular developers familiar with components and RxJS basics.
Angular version: 16+ (stable from v17+)


A Brief History: How We Got Here

Angular has always had a change detection story — and for most of its life, that story was Zone.js.

Zone.js works by monkey-patching async browser APIs (setTimeout, Promise, fetch, DOM events) to notify Angular when something might have changed. Angular then walks the entire component tree checking for updates. It's clever, but it comes with costs:

  • Performance: The whole tree is checked, even if only one tiny value changed.
  • Bundle size: Zone.js itself adds ~100 KB (minified + gzipped).
  • Debugging complexity: Async stack traces become opaque.
  • Framework coupling: Third-party libraries must be aware of Zone.js.

The Angular team began exploring an alternative reactivity model around 2022, drawing inspiration from fine-grained reactive systems already popular in SolidJS, Preact Signals, MobX, and Vue's Composition API. The result landed as a developer preview in Angular 16 (May 2023) and became stable in Angular 17 (November 2023).

Signals are now the recommended reactive primitive in Angular — and the foundation for future Zoneless Angular.


What Are Angular Signals?

A Signal is a reactive wrapper around a value. It tracks who reads it and notifies them when the value changes — automatically, synchronously, and without any scheduler magic.

Think of it as a variable that knows when it's been read and tells its dependants when it changes.

There are three core primitives:

Primitive Purpose
signal() Writable reactive state
computed() Derived read-only state
effect() Side-effects triggered by signal changes

Why Signals? The Benefits

1. Fine-Grained Reactivity

Only the parts of the UI that actually depend on a changed signal are re-rendered. No full tree traversal.

2. Synchronous & Predictable

Signal reads and writes are synchronous. No need to reason about async scheduling when tracking state changes.

3. No Zone.js Required

Signals are the cornerstone of Zoneless Angular — reducing bundle size and eliminating Zone.js headaches. (Opt-in as of Angular 18 with provideExperimentalZonelessChangeDetection().)

4. Explicit Dependency Tracking

Dependencies are tracked automatically at runtime when a signal is read inside computed() or effect(). No decorators, no annotations, no subscriptions to manage.

5. Better DevTools & Debugging

Because the dependency graph is explicit, tooling can surface it clearly. Angular DevTools already supports signal inspection.


How to Use Signals: Practical Examples

Creating a Writable Signal

import { signal } from '@angular/core';
 
const count = signal(0);
 
// Read the value — signals are functions, call them to read
console.log(count()); // 0
 
// Write a new value
count.set(1);
 
// Update based on the current value
count.update(v => v + 1);
 
// Mutate objects/arrays in place (signals referential equality check)
const todos = signal<string[]>([]);
todos.mutate(list => list.push('Buy milk'));

Computed Signals

computed() creates a read-only derived signal. Its value is lazily recalculated when its dependencies change.

import { signal, computed } from '@angular/core';
 
const price = signal(100);
const quantity = signal(3);
 
const total = computed(() => price() * quantity());
 
console.log(total()); // 300
 
price.set(120);
console.log(total()); // 360 — automatically updated

⚠️ computed() is lazy — it only recalculates when read after a dependency has changed, not on every dependency write. This makes it very efficient.

Effects

effect() runs a side-effect whenever the signals it reads change. It's perfect for logging, syncing to localStorage, or triggering non-Angular code.

import { signal, effect } from '@angular/core';
 
const theme = signal<'light' | 'dark'>('light');
 
effect(() => {
  document.body.setAttribute('data-theme', theme());
  console.log(`Theme changed to: ${theme()}`);
});
 
theme.set('dark'); // effect re-runs automatically

Important: effect() must be called inside an injection context (constructor, inject(), or runInInjectionContext()). Angular manages its lifetime and cleans it up when the component is destroyed.

Signals in Components

Here's a complete counter component using Signals:

import { Component, signal, computed } from '@angular/core';
 
@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div class="counter">
      <p>Count: {{ count() }}</p>
      <p>Double: {{ double() }}</p>
      <button (click)="increment()">+</button>
      <button (click)="decrement()">-</button>
      <button (click)="reset()">Reset</button>
    </div>
  `
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);
 
  increment() { this.count.update(v => v + 1); }
  decrement() { this.count.update(v => v - 1); }
  reset()     { this.count.set(0); }
}

Notice:

  • No ChangeDetectorRef needed.
  • No async pipe or subscriptions.
  • Template reads signals with () — Angular knows exactly which signals the template depends on.

Signal-Based Inputs, Outputs & Queries (Angular 17.1+)

Angular extended the Signal API to component metadata:

import { Component, input, output, viewChild } from '@angular/core';
import { ElementRef } from '@angular/core';
 
@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div #card>
      <h2>{{ name() }}</h2>
      <p>{{ role() }}</p>
      <button (click)="dismiss.emit()">Dismiss</button>
    </div>
  `
})
export class UserCardComponent {
  // Signal inputs — reactive and type-safe
  name = input.required<string>();
  role = input<string>('Member');  // with default
 
  // Signal output
  dismiss = output<void>();
 
  // Signal query
  card = viewChild.required<ElementRef>('card');
}

input() returns a read-only signal — you can use it in computed() and effect() just like any other signal.


RxJS ↔ Signal Interoperability

Signals and RxJS Observables serve overlapping but distinct purposes:

Signals Observables
Best for Synchronous UI state Async streams, HTTP, events
Lazy? Computed is lazy Generally lazy
Memory management Auto-cleaned Requires unsubscription
Multiple values? One current value Stream of values

Angular provides two bridge functions in @angular/core/rxjs-interop.

Observable → Signal: toSignal()

Convert an Observable into a Signal — great for consuming HTTP responses or async data sources.

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
 
interface User { id: number; name: string; }
 
@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    @if (users()) {
      @for (user of users()!; track user.id) {
        <p>{{ user.name }}</p>
      }
    } @else {
      <p>Loading...</p>
    }
  `
})
export class UsersComponent {
  private http = inject(HttpClient);
 
  users = toSignal(
    this.http.get<User[]>('/api/users'),
    { initialValue: null }
  );
}

No async pipe. No subscribe(). No manual unsubscription.

toSignal() automatically unsubscribes when the component is destroyed.

Signal → Observable: toObservable()

Convert a Signal into an Observable — useful for feeding signal state into RxJS pipelines (debouncing, HTTP requests, etc.).

import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
 
@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input [value]="query()" (input)="query.set($event.target.value)" placeholder="Search...">
    @for (result of results(); track result) {
      <p>{{ result }}</p>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);
 
  query = signal('');
 
  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      switchMap(q => this.http.get<string[]>(`/api/search?q=${q}`))
    ),
    { initialValue: [] }
  );
}

This is the power pattern: use Signals for synchronous UI state, use RxJS for async operations, and bridge them seamlessly.


Signal Effects: Cleanup & Equality

Custom Equality

By default, signals use === to determine if a value changed. You can provide a custom comparator:

import { signal } from '@angular/core';
 
const user = signal(
  { id: 1, name: 'Deepak' },
  { equal: (a, b) => a.id === b.id }  // Only re-notify if ID changes
);

Effect Cleanup

Effects can return a cleanup function, called before the next run or on destroy:

effect((onCleanup) => {
  const sub = externalEventBus.on('update', handler);
  onCleanup(() => sub.unsubscribe());
});

Quick Reference

// Create
const s = signal(0);
 
// Read
s()
 
// Write
s.set(42);
s.update(v => v + 1);
s.mutate(v => v.push('item')); // for objects/arrays
 
// Derive
const d = computed(() => s() * 2);
 
// React
effect(() => console.log(s()));
 
// Bridge
toSignal(observable$, { initialValue: undefined });
toObservable(mySignal);

Further Learning


Angular Signals represent a fundamental shift in how Angular manages reactivity — and with Zoneless Angular on the horizon, now is the perfect time to make them your default tool for UI state.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!