Imagine building a complex enterprise application—a CRM, an ERP dashboard, or a healthcare portal. Data is constantly changing: user inputs, real-time updates from a backend, or navigation events. How do you ensure your user interface (UI) always reflects the latest, correct state of your application without becoming a tangled mess of updates and potential bugs? This is the core challenge of state management, and it’s where modern Angular truly shines with Signals.

This chapter will guide you through Angular Signals, the powerful new reactivity primitive that simplifies how you manage changing data and ensures your UI stays perfectly in sync, efficiently and predictably. We’ll explore the fundamental building blocks of Signals, understand why they represent a significant leap forward for Angular, and implement them in a practical, step-by-step example. Mastering Signals is essential for any developer aiming to build high-performance, maintainable, and scalable Angular applications, especially in production environments where every millisecond and every line of code counts.

Before diving in, make sure you’re comfortable with Angular components, services, and basic data binding concepts from previous chapters. Signals build upon these fundamentals, offering a more direct and often more performant way to handle reactivity.

Angular Signals: A Modern Approach to Reactive State

Angular Signals, stable since Angular v17 (and foundational in Angular 21), fundamentally change how we think about reactivity and state management within the framework. They offer a direct, performant, and often simpler mechanism for managing application state, ensuring your UI dynamically updates as data changes.

What Problem Do Signals Solve?

In any dynamic application, data changes. A user types into an input, an API call returns new information, or a toggle is switched. The UI needs to react to these changes. Historically, Angular used Zone.js for broad change detection and RxJS Observables for complex data streams. While powerful, these could sometimes introduce complexities or performance overhead for simple, synchronous component state.

Signals provide a more explicit and fine-grained way to express dependencies. Instead of Angular trying to guess what might have changed, you explicitly declare your reactive values. This leads to:

  1. Simpler Mental Model: For local component state, Signals often provide a synchronous, pull-based model that’s easier to reason about than asynchronous streams.
  2. Fine-Grained Reactivity: Signals allow Angular’s change detection to be incredibly precise. When a signal’s value changes, Angular knows exactly which components or template bindings depend on it, updating only those specific parts. This drastically reduces unnecessary re-renders.
  3. Enhanced Performance: By performing less work during change detection, applications built with Signals can see significant performance improvements, especially in complex UIs with many dynamic data points. It also paves the way for future zone-less applications in Angular.

🧠 Important: Signals are a powerful addition to Angular’s toolkit, not a wholesale replacement for RxJS. RxJS remains invaluable for complex asynchronous operations, event streams, and advanced data manipulation (e.g., debouncing user input, combining multiple API calls). Signals excel at managing synchronous, fine-grained state within your application. They are complementary tools.

The Core Signal Primitives: Building Blocks of Reactivity

Angular provides three primary functions to work with Signals: signal(), computed(), and effect(). Each serves a distinct purpose in managing and reacting to state changes.

1. signal(): Creating Writable State

The signal() function is your starting point for any piece of data that needs to be reactive and mutable.

  • What it is: A function that returns a WritableSignal<T> instance, where T is the type of the value it holds.
  • Why it exists: To encapsulate a piece of state that can be read and updated, notifying any consumers about its changes. It’s the simplest form of reactive data.
  • How it functions:
    • You initialize it with a default value.
    • You retrieve its current value by calling the signal like a function (e.g., mySignal()).
    • You update its value using the .set() method (to replace the value entirely) or the .update() method (to modify the value based on its current state).

Let’s illustrate:

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

// Create a signal to track a user's login status
const isLoggedIn = signal(false);

console.log('User logged in?', isLoggedIn()); // Output: User logged in? false

// Update the signal using .set()
isLoggedIn.set(true);
console.log('User logged in?', isLoggedIn()); // Output: User logged in? true

// Create a signal for a counter
const clickCount = signal(0);
console.log('Initial clicks:', clickCount()); // Output: Initial clicks: 0

// Update the signal based on its current value using .update()
clickCount.update(currentValue => currentValue + 1);
console.log('Clicks after update:', clickCount()); // Output: Clicks after update: 1

⚡ Real-world insight: In a production application, signal() is perfect for managing component-local state like a toggle’s status, the current value of an input field, or the visibility of a modal. It makes these common UI interactions highly reactive and easy to manage.

2. computed(): Derived State and Efficient Calculations

Often, one piece of state logically depends on others. For instance, a user’s full name is derived from their first and last names. A computed() signal automatically re-evaluates whenever any of its dependent signals change, ensuring derived state is always up-to-date.

  • What it is: A function that returns a read-only Signal<T>. Its value is the result of a calculation based on other signals.
  • Why it exists: To efficiently create derived values that are always consistent with their sources. It prevents manual recalculations and potential inconsistencies.
  • How it functions: It takes a function (a “computation function”) that reads one or more signals. Whenever any of these input signals change, the computed signal’s function re-runs, and its value updates. computed() signals are lazy and memoized, meaning they only recompute when their value is read and a dependency has changed.
import { signal, computed } from '@angular/core';

const firstName = signal('Alice');
const lastName = signal('Wonderland');

// Create a computed signal for the full name
const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log('Full Name:', fullName()); // Output: Full Name: Alice Wonderland

// Change a dependency
firstName.set('Bob');
console.log('New Full Name:', fullName()); // Output: New Full Name: Bob Wonderland

// Change another dependency
lastName.set('Builder');
console.log('Latest Full Name:', fullName()); // Output: Latest Full Name: Bob Builder

🔥 Optimization / Pro tip: computed() is incredibly efficient. Angular only re-runs the computation function if one of its dependencies has actually changed, and it only notifies its own consumers if its own output value has changed. This memoization is key to performance in complex UIs.

3. effect(): Synchronizing State with the Outside World

Sometimes you need to trigger a side effect—code that runs when a signal changes but doesn’t produce a new reactive value for another signal or directly update the template. This is the domain of effect(). Effects are for synchronizing signal state with external systems or directly manipulating the browser DOM outside of Angular’s template binding.

  • What it is: A function that runs a callback whenever its signal dependencies change. It doesn’t return a value.
  • Why it exists: For “side effects” like logging to the console, manually interacting with browser APIs (e.g., document.title), storing data in localStorage, or integrating with non-Angular libraries.
  • How it functions: It takes a function that reads one or more signals. This function is executed immediately upon creation, and then automatically re-executed whenever any of the signals it reads change.
import { signal, effect } from '@angular/core';

const notificationCount = signal(0);

// Create an effect that updates the browser tab title
effect(() => {
  document.title = `(${notificationCount()}) New Notifications`;
});

// The effect runs immediately:
// (Browser title becomes: "(0) New Notifications")

notificationCount.set(3);
// The effect runs again:
// (Browser title becomes: "(3) New Notifications")

notificationCount.update(value => value + 1);
// The effect runs again:
// (Browser title becomes: "(4) New Notifications")

⚠️ What can go wrong: Avoid using effect() for tasks that computed() can handle (i.e., deriving new state) or for tasks that template bindings can handle. Overusing effects can lead to hard-to-follow logic and potential performance issues if not carefully managed. Effects are specifically for side effects that interact with the world outside of Angular’s reactive graph.

Step-by-Step Implementation: Building a Simple Task Manager with Signals

Let’s apply our knowledge by building a straightforward task manager. We’ll use signal(), computed(), and effect() to manage our task list, track completed items, and demonstrate reactivity in action.

Project Setup

First, generate a new standalone component for our task list. Standalone components are the recommended approach in Angular 21, simplifying module management.

ng generate component task-list --standalone

This command creates src/app/task-list/task-list.component.ts, task-list.component.html, and task-list.component.css.

Step 1: Define Task State with signal()

We’ll begin by defining an interface for our tasks and then create several signal instances to hold our application’s state.

Open src/app/task-list/task-list.component.ts and replace its content with the following:

import { Component, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common'; // Needed for *ngFor, *ngIf
import { FormsModule } from '@angular/forms';   // Needed for [(ngModel)]

// Define the structure of a Task object
interface Task {
  id: number;
  description: string;
  completed: boolean;
}

@Component({
  selector: 'app-task-list',
  standalone: true,
  imports: [CommonModule, FormsModule], // Import necessary Angular modules
  templateUrl: './task-list.component.html',
  styleUrl: './task-list.component.css'
})
export class TaskListComponent {
  // Our main signal to hold the array of tasks. Initialized as an empty array.
  tasks = signal<Task[]>([]);

  // Signal to bind to the input field for adding new tasks.
  newTaskDescription = signal('');

  // Helper signal to generate unique IDs for new tasks.
  nextId = signal(1);

  // The constructor is a good place to set up effects
  constructor() {
    // This effect will run whenever the number of completed tasks changes
    effect(() => {
      console.log('📢 Effect: Completed tasks count changed to:', this.completedTasksCount());
    });
  }

  // --- Methods for task management will go here ---
}

Explanation:

  • We import signal, computed, and effect from @angular/core.
  • CommonModule is essential for using structural directives like *ngFor and *ngIf in our template.
  • FormsModule is imported to enable two-way data binding with [(ngModel)] on our input field.
  • The Task interface provides type safety for our task objects.
  • tasks = signal<Task[]>([]); initializes our primary state: an empty array of Task objects wrapped in a signal.
  • newTaskDescription = signal(''); is for the text input where users type new task descriptions.
  • nextId = signal(1); is a simple counter to ensure each new task gets a unique ID.
  • The constructor initializes an effect that logs the completedTasksCount (which we’ll define soon) to the console, demonstrating a simple side effect.

Step 2: Display Tasks in the Template

Next, let’s update src/app/task-list/task-list.component.html to render our task list.

<h2 class="section-title">My Tasks</h2>

<div *ngIf="tasks().length === 0" class="no-tasks-message">
  You have no tasks yet! Add one below to get started.
</div>

<ul class="task-list">
  <li *ngFor="let task of tasks()" [class.completed]="task.completed">
    <input
      type="checkbox"
      [checked]="task.completed"
      (change)="toggleTaskComplete(task.id)"
    />
    <span class="task-description">{{ task.description }}</span>
  </li>
</ul>

<!-- Input field and buttons will be added here in the next steps -->

Explanation:

  • *ngIf="tasks().length === 0" conditionally displays a message when the tasks signal’s array is empty. Notice the () after tasks to read its current value.
  • *ngFor="let task of tasks()" iterates over the array value held by the tasks signal.
  • [class.completed]="task.completed" dynamically applies the completed CSS class if task.completed is true.
  • The checkbox [checked] property binds to task.completed.
  • (change)="toggleTaskComplete(task.id)" sets up an event listener to call a method when the checkbox state changes.

Step 3: Add New Tasks

Now, let’s add an input field and a button to our task-list.component.html to allow users to create new tasks.

Add this code below the </ul> in task-list.component.html:

<div class="add-task-form">
  <input
    type="text"
    placeholder="Enter a new task..."
    [(ngModel)]="newTaskDescription"
    (keyup.enter)="addTask()"
  />
  <button (click)="addTask()">Add Task</button>
</div>

Then, add the addTask() method to your task-list.component.ts file, inside the TaskListComponent class:

// ... (inside TaskListComponent class)

  addTask(): void {
    const description = this.newTaskDescription().trim(); // Read the input signal's value
    if (description) {
      // Use .update() to create a new array with the new task
      this.tasks.update(currentTasks => [
        ...currentTasks, // Spread existing tasks
        { id: this.nextId(), description, completed: false } // Add new task
      ]);
      this.newTaskDescription.set(''); // Clear the input field signal
      this.nextId.update(id => id + 1); // Increment ID for the next task
    }
  }

Explanation:

  • [(ngModel)]="newTaskDescription" uses two-way data binding. The input field’s value is synchronized with the newTaskDescription signal.
  • (keyup.enter)="addTask()" triggers the addTask method when the Enter key is pressed.
  • Inside addTask(), we first read the current value of newTaskDescription() using ().
  • this.tasks.update(...) is critical. We don’t mutate the existing array directly (e.g., this.tasks().push(...)). Instead, update() receives the currentTasks array and expects us to return a new array reference. We use the spread operator (...currentTasks) to create a new array that includes all previous tasks plus the new one. This ensures Angular’s reactivity system detects a change in the tasks signal.
  • Finally, we clear the input field by calling this.newTaskDescription.set('') and increment our nextId signal.

Step 4: Mark Tasks as Complete

Now, let’s implement the toggleTaskComplete() method, which will be called when a task’s checkbox is clicked.

Add this to task-list.component.ts, inside the TaskListComponent class, after addTask():

// ... (inside TaskListComponent class, after addTask)

  toggleTaskComplete(id: number): void {
    this.tasks.update(currentTasks =>
      currentTasks.map(task =>
        task.id === id ? { ...task, completed: !task.completed } : task
      )
    );
  }

Explanation:

  • toggleTaskComplete() takes the id of the task to modify.
  • Again, we use this.tasks.update(). The callback function maps over the currentTasks array.
  • For the task matching the given id, we create a new task object using the spread operator ({ ...task, completed: !task.completed }) to toggle its completed status. For all other tasks, we return them as they are. This pattern ensures that we always return a new array and new task objects for any modified items, which is essential for Signals to detect changes and trigger updates correctly.

Step 5: Derived State with computed()

It’s useful to see how many tasks are completed. Let’s add a computed signal to automatically track this.

Add this to task-list.component.ts, inside TaskListComponent, after the nextId signal:

// ... (inside TaskListComponent class, after nextId signal)

  // A computed signal that derives its value from the 'tasks' signal
  completedTasksCount = computed(() => this.tasks().filter(task => task.completed).length);

Then, display this count in task-list.component.html. Add this below the <h2> tag:

<p class="task-summary">
  Total tasks: {{ tasks().length }} | Completed: {{ completedTasksCount() }}
</p>

Explanation:

  • completedTasksCount is a computed signal. Its value depends directly on this.tasks().
  • Whenever this.tasks() changes (e.g., a task is added, removed, or its completed status is toggled), the computed function filter(task => task.completed).length automatically re-runs.
  • The completedTasksCount() signal will then hold the new, correct value, and any template binding to it will update.
  • In the template, we call completedTasksCount() to display its current value.

Step 6: Integrate TaskListComponent into AppComponent

To see your task manager in action, open src/app/app.component.ts. You need to import TaskListComponent and add its selector to AppComponent’s template.

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { TaskListComponent } from './task-list/task-list.component'; // Import your new component

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, TaskListComponent], // Add TaskListComponent here
  template: `
    <main class="app-container">
      <h1 class="app-title">Angular 21 Signal Task Manager</h1>
      <app-task-list></app-task-list> <!-- Use your task-list component -->
    </main>
  `,
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'angular-signals-app';
}

Now, run your application with ng serve in your terminal and navigate to http://localhost:4200 in your browser. You should see your task manager. Add tasks, mark them complete, and observe how the “Completed” count in the UI and the console log from the effect automatically update.

Styling (Optional)

For better visual presentation, you can add some basic styles.

src/app/task-list/task-list.component.css:

.section-title {
  color: #2c3e50;
  text-align: center;
  margin-bottom: 20px;
}

.no-tasks-message {
  color: #7f8c8d;
  font-style: italic;
  text-align: center;
  margin-bottom: 20px;
}

.task-list {
  list-style-type: none;
  padding: 0;
  margin-top: 15px;
  border: 1px solid #ecf0f1;
  border-radius: 6px;
  background-color: #ffffff;
}

.task-list li {
  display: flex;
  align-items: center;
  padding: 12px 15px;
  border-bottom: 1px solid #ecf0f1;
}

.task-list li:last-child {
  border-bottom: none;
}

.task-list li.completed .task-description {
  text-decoration: line-through;
  color: #95a5a6;
}

.task-list li input[type="checkbox"] {
  margin-right: 15px;
  transform: scale(1.3);
  cursor: pointer;
  accent-color: #3498db; /* Custom color for checkbox */
}

.task-description {
  flex-grow: 1;
  font-size: 1.1em;
  color: #34495e;
}

.add-task-form {
  margin-top: 25px;
  display: flex;
  gap: 10px;
}

.add-task-form input {
  flex-grow: 1;
  padding: 10px 12px;
  border: 1px solid #bdc3c7;
  border-radius: 5px;
  font-size: 1em;
  outline: none;
}

.add-task-form input:focus {
  border-color: #3498db;
  box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}

.add-task-form button {
  padding: 10px 20px;
  background-color: #2ecc71;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease;
}

.add-task-form button:hover {
  background-color: #27ae60;
}

.task-summary {
  margin-top: 20px;
  font-weight: bold;
  color: #34495e;
  text-align: center;
  padding: 10px;
  background-color: #ecf0f1;
  border-radius: 6px;
}

.clear-button {
  display: block;
  margin: 20px auto 0;
  padding: 10px 20px;
  background-color: #e74c3c;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease;
}

.clear-button:hover {
  background-color: #c0392b;
}

src/app/app.component.css:

.app-container {
  max-width: 600px;
  margin: 50px auto;
  padding: 30px;
  border: 1px solid #ddd;
  border-radius: 10px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background-color: #f9f9f9;
}

.app-title {
  color: #2c3e50;
  text-align: center;
  margin-bottom: 30px;
  font-size: 2.2em;
  font-weight: 600;
}

Mini-Challenge: Clear Completed Tasks

You’ve built a solid foundation for your task manager. Now, let’s add a common feature to further solidify your understanding of Signals.

Challenge: Implement a “Clear Completed Tasks” button. When clicked, this button should remove all tasks that are currently marked as completed from your tasks signal.

Steps:

  1. Add a new <button> element to task-list.component.html, perhaps below the task summary or next to the “Add Task” button. Give it a suitable class for styling (e.g., clear-button).
  2. Bind a (click) event to this new button, calling a new method like clearCompletedTasks().
  3. Create the clearCompletedTasks() method in task-list.component.ts.
  4. Inside this method, use this.tasks.update() to filter the currentTasks array, keeping only those tasks where completed is false.

Hint: Remember the pattern for updating array signals: this.myArraySignal.update(currentArray => currentArray.filter(...)).

What to observe/learn: As you click the “Clear Completed Tasks” button, observe how the completedTasksCount (your computed signal) and the effect (logging to the console) automatically react and update. This immediate, cascading update without any manual intervention highlights the powerful, declarative nature of Angular Signals.

Common Pitfalls & Troubleshooting with Signals

While Signals are designed for simplicity, there are a few common traps developers can fall into:

  1. Forgetting to call () to read a Signal:

    • Mistake: Using this.mySignal directly in a template or component logic instead of this.mySignal().
    • Symptom: You’ll likely encounter a TypeError: this.mySignal is not a function or, worse, stale data because you’re referencing the signal object itself, not its current value.
    • Solution: Always call a signal like a function (mySignal()) to retrieve its current value. This is a fundamental aspect of the Signals API.
  2. Mutating Signal Values Directly (Instead of set() or update()):

    • Mistake: For an array or object signal, doing this.myArraySignal().push(newItem) or this.myObjectSignal().property = newValue.
    • Symptom: The UI doesn’t update, even though the underlying data has changed. Signals only detect when the reference to the value changes, not internal mutations of the object/array that the signal currently holds.
    • Solution: Always use signal.set(newValue) or signal.update(oldValue => newValue) to provide a new reference to the signal. For arrays or objects, this often means creating a new array/object using spread syntax (e.g., [...oldArray, newItem] or { ...oldObject, newProperty: value }). Immutability is key here.
  3. Overusing effect() for Derived State:

    • Mistake: Using effect to calculate and store a value that depends on other signals, which then might be consumed by the template or other logic.
    • Symptom: Less efficient code, potential for unnecessary re-runs, and a less clear separation of concerns. effect is for side effects (like logging, DOM manipulation), not for producing new reactive values that other parts of the application will consume.
    • Solution: If you need a value that automatically updates when its dependencies change and will be consumed elsewhere (e.g., in the template or another signal), use computed(). computed() provides memoization and is designed for this exact purpose.

AI-Generated Code Pitfalls for Signals

AI coding assistants (like Claude, Copilot, Codex) are powerful, but they learn from vast datasets, which can include older or suboptimal code. When working with cutting-edge features like Angular Signals, be vigilant.

⚠️ What can go wrong:

  • Outdated Patterns: AI models trained on older data might suggest RxJS-heavy solutions for simple component state where Signals would be more appropriate and performant in Angular 21. For example, it might suggest using a BehaviorSubject for a simple counter when signal(0) is much cleaner.
  • Incorrect Signal API Usage: They might forget to use () when reading a signal, or suggest direct mutation of signal values (mySignal().property = value) instead of the correct .set() or .update() methods, leading to non-reactive updates.
  • Misuse of effect(): An AI might propose using effect for derived state, falling into the pitfall mentioned above, rather than leveraging the efficiency of computed().

⚡ Real-world insight: When using AI for Angular 21 code, clarity and specificity in your prompts are paramount. Always specify the Angular version and the desired patterns. For example: “Generate an Angular 21 standalone component for a product list, using Signals for all local component state management.” After generation, critically review the code for:

  • Correct use of signal(), computed(), and effect().
  • Always calling signals with () for reading their values.
  • Using set() or update() for writing to signals, especially when dealing with arrays and objects (ensuring immutability).
  • Appropriate use of computed for derived state and effect for true side effects that don’t produce a value for Angular’s reactivity graph.

Your foundational understanding of Signals is your most powerful tool. The AI is an assistant; you remain the architect and the ultimate quality gate for your code.

Summary

You’ve successfully mastered Angular Signals, a cornerstone of modern Angular development that brings fine-grained reactivity and enhanced performance to your applications.

Here are the key takeaways from this chapter:

  • signal(): The fundamental building block for creating mutable, reactive state. Read its value with mySignal() and update it with mySignal.set(newValue) or mySignal.update(callback).
  • computed(): For efficiently deriving new state from existing signals. It automatically re-evaluates when its dependencies change and is read-only. It’s lazy and memoized for performance.
  • effect(): For running side effects (like logging, DOM manipulation, localStorage updates) when signal dependencies change. Avoid using it for producing derived state.
  • Fine-grained Reactivity: Signals enable Angular to update the UI more efficiently by knowing precisely which parts depend on a changed signal, leading to better performance, especially in future zone-less applications.
  • Complementary to RxJS: Signals are excellent for local, synchronous state management, while RxJS remains ideal for complex asynchronous data streams and event handling.
  • AI Tools & Best Practices: Leverage AI for code generation, but always critically review its output, especially for modern Angular patterns like Signals. Explicitly state Angular version and desired patterns in your prompts, and verify correct API usage and architectural choices.

Mastering Signals empowers you to build more performant, predictable, and maintainable Angular applications. This foundational understanding of reactive state management is a highly reusable skill that will serve you well across various frontend frameworks and backend systems.

In the next chapter, we’ll expand on state management strategies, exploring how to combine Signals with services for more complex, application-wide state management in enterprise applications, preparing you for robust, scalable architectures.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.