DEV Community

Cover image for Angular Signals: A Comprehensive Guide
hassantayyab
hassantayyab

Posted on โ€ข Edited on

Angular Signals: A Comprehensive Guide

Introduction

Angular signals represent a paradigm shift in state management and reactivity, offering developers a streamlined, declarative approach. From foundational principles to advanced concepts like testing and RxJS integration, this guide will provide you with everything you need to master Angular signals, supported by detailed examples and practical tips. ๐ŸŒŸโœจ๐ŸŽฏ


Core Concepts

What Are Angular Signals?

Signals in Angular are reactive primitives that track and react to data changes automatically. They reduce boilerplate code and enhance performance by enabling fine-grained reactivity. ๐Ÿ”„โšก๐Ÿ“ˆ

Core Features

  • Declarative Reactivity: Signals explicitly declare their dependencies.
  • Optimized Change Detection: Updates propagate efficiently, minimizing unnecessary recalculations.
  • Debugging Support: Angular provides tools to inspect signal dependencies and values.

Usage of Signals

Creating Signals

Define a signal using the signal function. Signals hold a reactive value that can be read or updated: ๐Ÿ”ง๐Ÿ’ก๐Ÿ“Š

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

// Define a signal with an initial value of 0
const counter = signal(0);

// Read the current value of the signal
console.log(counter()); // Outputs: 0

// Update the signal value
counter.set(1);
console.log(counter()); // Outputs: 1
Enter fullscreen mode Exit fullscreen mode

Using Signals in Components

Signals integrate seamlessly into Angular templates: ๐Ÿ–ผ๏ธ๐Ÿ“‹๐Ÿ‘จโ€๐Ÿ’ป

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

@Component({
  selector: 'app-counter',
  template: `
    <p>Counter: {{ counter() }}</p>
    <button (click)="increment()">Increment</button>
  `,
})
export class CounterComponent {
  // Define a signal to manage the counter state
  counter = signal(0);

  // Method to increment the counter
  increment() {
    this.counter.update(value => value + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Updating Signals

Signals offer various methods to update their values: ๐Ÿ› ๏ธ๐Ÿ’พ๐Ÿ“Œ

Signals in Angular provide flexible methods to update their values based on different scenarios:

Using set

The set method directly assigns a new value to the signal.

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

// Define a signal with an initial value
const counter = signal(0);

// Directly set the signal to a new value
counter.set(10);
console.log(counter()); // Outputs: 10
Enter fullscreen mode Exit fullscreen mode

Using update

The update method allows you to modify the current value based on its existing state.

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

// Define a signal with an initial value
const counter = signal(0);

// Increment the signal's value by 5
counter.update(value => value + 5);
console.log(counter()); // Outputs: 5
Enter fullscreen mode Exit fullscreen mode

Using mutate

The mutate method is ideal for modifying complex data structures like arrays or objects directly.

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

// Signal managing a list of numbers
const numbers = signal([1, 2, 3]);

// Mutate the signal by adding an element to the array
numbers.mutate(arr => arr.push(4));
console.log(numbers()); // Outputs: [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

Summary of Update Methods

  • set: Use for direct value replacement.
  • update: Use for transformations based on the current value.
  • mutate: Use for in-place modifications of complex structures.

Advanced Topics

Derived Signals

Derived signals use the computed function to reactively calculate values based on other signals: ๐Ÿ”„๐Ÿงฎ๐ŸŒŸ

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

// Define a base signal
const base = signal(5);

// Define a derived signal that depends on the base signal
const double = computed(() => base() * 2);

console.log(double()); // Outputs: 10

// Update the base signal value
base.set(10);
console.log(double()); // Outputs: 20
Enter fullscreen mode Exit fullscreen mode

RxJS Interop with Angular Signals

Angular provides the @angular/rxjs-interop package for seamless integration between RxJS Observables and Angular signals. This includes utilities like toSignal and toObservable.

Creating a Signal from an Observable with toSignal

The toSignal function converts an Observable into a Signal, allowing you to use Observables reactively in Angular templates or logic.

import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  template: `{{ counter() }}`,
})
export class Ticker {
  counterObservable = interval(1000);

  // Convert Observable to Signal
  counter = toSignal(this.counterObservable, { initialValue: 0 });
}
Enter fullscreen mode Exit fullscreen mode
  • Automatic Subscription: The subscription created by toSignal unsubscribes when the component is destroyed.
  • Initial Value: You can set an initial value for the signal, which is used before the Observable emits.

Advanced Options for toSignal

  1. initialValue: Define a fallback value before the Observable emits.
  2. requireSync: Ensure the Observable emits synchronously (e.g., with BehaviorSubject).
  3. manualCleanup: Manage the Observableโ€™s lifecycle manually if needed.
import { BehaviorSubject } from 'rxjs';

const subject = new BehaviorSubject(42);
const syncSignal = toSignal(subject, { requireSync: true });
console.log(syncSignal()); // Outputs: 42
Enter fullscreen mode Exit fullscreen mode

Errors and Completion

  • If the Observable produces an error, the signal throws the error when accessed.
  • When the Observable completes, the signal retains the last emitted value.

Injection Context

toSignal operates within Angular's injection context. If unavailable, you can manually provide an Injector.

Creating an Observable from a Signal with toObservable

The toObservable utility converts a Signal into an Observable, making it compatible with RxJS pipelines.

import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';

@Component({
  selector: 'app-search-results',
  template: `<div>Search Results</div>`
})
export class SearchResults {
  query = signal(''); // Define a signal for search queries

  // Convert Signal to Observable
  query$ = toObservable(this.query);

  // Use the Observable in an RxJS pipeline
  results$ = this.query$.pipe(
    switchMap(query => this.http.get(`/search?q=${query}`))
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Timing: Signals stabilize changes before toObservable emits.
  • Injection Context: Similar to toSignal, toObservable requires an Angular injection context.

Effects

Effects allow you to execute side effects whenever a signalโ€™s value changes: ๐ŸŒ๐Ÿ”„โš™๏ธ

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

// Define a signal for a count value
const count = signal(0);

// Effect to log the count value whenever it changes
effect(() => {
  console.log(`Count is now: ${count()}`);
});

// Update the signal value
count.set(5); // Logs: Count is now: 5
Enter fullscreen mode Exit fullscreen mode

Dependency Tracking

Signals automatically track their dependencies, ensuring updates propagate only when necessary. This reduces unnecessary computations and boosts performance. ๐Ÿ”โšกโœ…

Signal Lifecycles

Signals clean up automatically when a component is destroyed. For manual resource management, use the cleanup callback: ๐Ÿงน๐Ÿ”„โ™ป๏ธ

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

// Define an effect with a cleanup callback
effect(() => {
  const interval = setInterval(() => console.log('Running...'), 1000);

  // Cleanup the interval when the effect is disposed
  return () => clearInterval(interval);
});
Enter fullscreen mode Exit fullscreen mode

Testing Angular Signals

Unit Testing Signals

Use Angularโ€™s testing utilities to verify signal behavior: ๐Ÿงชโœ…๐Ÿ”ฌ

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

describe('Signal Tests', () => {
  it('should update signal value', () => {
    const count = signal(0);

    // Set a new value for the signal
    count.set(5);

    // Assert the updated value
    expect(count()).toBe(5);
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Effects

Mock dependencies and track side effects: ๐Ÿ› ๏ธ๐Ÿ“ˆ๐Ÿ”

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

describe('Effect Tests', () => {
  it('should log changes', () => {
    const spy = jest.fn();
    const count = signal(0);

    // Define an effect that logs changes
    effect(() => spy(count()));

    // Update the signal and assert the effect
    count.set(5);
    expect(spy).toHaveBeenCalledWith(5);
  });
});
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Use Signals for Local State: Avoid using signals for complex, app-wide state. ๐ŸŽฏโœ”๏ธ๐Ÿ“
  2. Keep Computed Signals Pure: Avoid side effects in computed functions. ๐ŸŽฏ๐Ÿ› ๏ธ๐Ÿงผ
  3. Leverage Effects for Side Effects: Separate side effects from state updates. ๐ŸŽฏโš™๏ธ๐Ÿ”„
  4. Integrate with RxJS: Use signals for lightweight reactivity and RxJS for complex asynchronous data streams. ๐ŸŽฏ๐Ÿ”—๐ŸŒ

Cheat Sheet

Signal Basics

  • Create a signal: const mySignal = signal(initialValue);
  • Read a signal: mySignal()
  • Update a signal: mySignal.set(newValue);
  • Modify a signal: mySignal.update(value => value + 1);
  • Mutate objects/arrays: mySignal.mutate(obj => { obj.key = value; });

Derived Signals

  • Create a derived signal: const derived = computed(() => mySignal() * 2);

Effects

  • Run side effects: effect(() => console.log(mySignal()));
  • Cleanup effects: return () => cleanupLogic();

RxJS Integration

  • Convert to Signal: toSignal(observable, { options });
  • Convert to Observable: toObservable(signal);

Testing Signals

  • Assert signal value: expect(mySignal()).toBe(expectedValue);
  • Test effects: Use spies or mocks to track calls.

Conclusion

Angular Signals represent a significant step forward in state management and reactivity.
They offer:

  • Improved performance through fine-grained reactivity
  • Cleaner, more maintainable code
  • Better developer experience
  • Seamless integration with existing Angular features

As you begin implementing Signals in your applications, start small with component-level state and gradually expand to more complex scenarios. The benefits become increasingly apparent as your application grows.

Resources

Remember, Signals are still evolving, and best practices will continue to emerge as the community adopts this powerful feature. Stay tuned for updates and keep experimenting with different patterns in your applications.

Top comments (2)

Collapse
 
gerardoparrajc profile image
Gerardo Parra โ€ข

Hi, the article is a good reference for initiating to signals, but I don't find any reference to mutate method in signals. Where can I find documentation about it?
Thanks

Collapse
 
hassantayyab profile image
hassantayyab โ€ข

Hey Gerardo, the Angular team has dropped the mutate method for now because of internal code issues it caused. But it might come in the future.

Thanks for pointing this out. :)

Sentry image

See why 4M developers consider Sentry, โ€œnot bad.โ€

Fixing code doesnโ€™t have to be the worst part of your day. Learn how Sentry can help.

Learn more