On this page

Signals: reactive primitives in Angular

15 min read TextCh. 2 — Reactivity

What are Signals?

Signals are the core reactive primitive of Angular 21. They are value containers that automatically notify their consumers when they change. They progressively replace the Zone.js-based system.

Creating a signal

Use signal(initialValue) to create a mutable signal:

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

// Signal with inferred type (number)
const counter = signal(0);

// Signal with explicit type
const name = signal<string | null>(null);

// Signal with complex value
const user = signal({ name: 'Ana', age: 28 });

Reading a signal

Signals are functions. Call the signal to read its value:

console.log(counter());  // 0
console.log(name());     // null

In templates, they are also called as functions:

<p>Counter: {{ counter() }}</p>

Writing to a signal

There are two ways to update a signal:

`.set(newValue)` — Complete replacement

counter.set(10);
name.set('Carlos');

`.update(fn)` — Based on the previous value

counter.update(value => value + 1);
name.update(n => n ? n.toUpperCase() : 'ANON');

For collections, always create a new array or object:

const items = signal<string[]>(['a', 'b']);

// Add element
items.update(list => [...list, 'c']);

// Remove element
items.update(list => list.filter(item => item !== 'b'));

// Modify element
items.update(list =>
  list.map(item => item === 'a' ? 'A' : item)
);

Computed signals

computed() creates a read-only signal that derives from other signals:

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

const price = signal(100);
const quantity = signal(3);
const tax = signal(0.16);

const subtotal = computed(() => price() * quantity());
const total = computed(() => subtotal() * (1 + tax()));

Characteristics of computed():

  • Read-only — You cannot use .set() or .update()
  • Lazy — Only recalculates when read
  • Memoized — Caches the result until dependencies change
  • Auto-tracking — Dependencies are detected automatically

Signal vs Observable

Feature Signal Observable
Reading Synchronous: signal() Asynchronous: subscribe()
Current value Always available Not guaranteed
Reactivity Automatic in templates Requires async pipe
Composition computed() pipe() + operators
Use case UI state Events, HTTP, streams

Best practices

  1. Use readonly in declarations to prevent reassignment
  2. Prefer computed() over effects for deriving state
  3. Keep signals granular — One signal per concept
  4. Never mutate the internal value — Always create new references

Practice

  1. Build a counter with signals: Implement a component with a signal(0) and buttons to increment, decrement, and reset. Display the value in the template by calling the signal as a function.
  2. Add a computed signal: Create a computed() that derives whether the counter is even or odd, and another that calculates double the value. Display both in the template.
  3. Manage a list: Create a signal with a string array. Implement methods to add and remove items using .update() with the spread operator, without mutating the original array.

In the next lesson, we will learn about effects and the resource API for handling side effects.

Never mutate directly
Never do mySignal().push(item) or modify properties of the internal object. Always use .set() or .update() to create a new value. Signals detect changes by reference.
Computed is lazy
A computed() only recalculates when it is read AND one of its dependencies has changed. Angular tracks dependencies automatically; you don't need to declare them.
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';

interface Task {
  id: number;
  text: string;
  completed: boolean;
  priority: 'high' | 'medium' | 'low';
}

@Component({
  selector: 'app-task-manager',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './task-manager.html',
})
export class TaskManager {
  readonly tasks = signal<Task[]>([
    { id: 1, text: 'Learn signals', completed: false, priority: 'high' },
    { id: 2, text: 'Create components', completed: true, priority: 'medium' },
    { id: 3, text: 'Configure routing', completed: false, priority: 'low' },
  ]);

  readonly priorityFilter = signal<string>('all');

  readonly filteredTasks = computed(() => {
    const filter = this.priorityFilter();
    const list = this.tasks();
    if (filter === 'all') return list;
    return list.filter(t => t.priority === filter);
  });

  readonly stats = computed(() => {
    const list = this.tasks();
    return {
      total: list.length,
      completed: list.filter(t => t.completed).length,
      pending: list.filter(t => !t.completed).length,
      percentage: list.length
        ? Math.round((list.filter(t => t.completed).length / list.length) * 100)
        : 0,
    };
  });

  addTask(text: string, priority: 'high' | 'medium' | 'low'): void {
    this.tasks.update(list => [
      ...list,
      { id: Date.now(), text, completed: false, priority },
    ]);
  }

  toggleTask(id: number): void {
    this.tasks.update(list =>
      list.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  removeTask(id: number): void {
    this.tasks.update(list => list.filter(t => t.id !== id));
  }
}