Advanced Angular 21 Evolution and Signals Deep Dive
Signal Core Mechanics
Angular's New Reactivity Engine
For years, Angular's change detection relied on a library called Zone.js to know when to re-render parts of an application. It worked by patching browser APIs, like event listeners or timers, and triggering a check of the entire component tree whenever something might have changed. This was effective but could be inefficient in large applications, often re-checking components that hadn't actually changed.
Angular Signals introduce a fundamentally different approach. Instead of broad, top-down checks, signals create a precise, fine-grained reactivity system. Think of it as a network of producers and consumers. When a producer's value changes, it directly notifies only the consumers that depend on it. This surgical approach avoids unnecessary work and leads to significant performance gains.
This model builds a dependency graph, allowing Angular to update only the specific parts of the DOM that are affected by a state change, without dirty-checking the entire component tree.
The Three Signal Primitives
The entire system is built on three core functions, or primitives: signal, computed, and effect. Each plays a distinct role in managing and reacting to your application's state.
signal: A writable container for a piece of state. This is your source of truth.computed: A read-only value derived from one or more other signals. It automatically updates when its dependencies change.effect: An operation that runs whenever one of its dependent signals changes. It's used for side effects, like logging or updating the DOM.
Writable Signals: The Source of Truth
A writable signal is the foundation of state management. You create one by calling the signal() function with an initial value. To read the value, you call the signal as a function. To change it, you use the .set() or .update() methods.
import { signal } from '@angular/core';
// Create a signal with an initial value
const count = signal(0);
// Read the value (returns 0)
console.log(count());
// Set a new value directly
count.set(5);
// Update the value based on the current value
count.update(currentValue => currentValue + 1);
console.log(count()); // returns 6
The .update() method is useful for state transitions that depend on the previous value, ensuring atomicity. For signals holding objects or arrays, Angular performs a simple reference check (===) to see if the value has changed. You can provide a custom equality function to define more complex comparison logic, for example, to check if an object's properties have changed.
Computed Signals and Effects
Computed signals derive their value from other signals. They are both producers and consumers: they consume other signals to produce a new, read-only value. Angular lazily evaluates them, meaning a computed signal only recalculates its value when it's read and one of its dependencies has changed. This memoization prevents redundant calculations.
const price = signal(10);
const quantity = signal(5);
// Create a computed signal
const total = computed(() => price() * quantity());
console.log(total()); // returns 50
price.set(12);
console.log(total()); // automatically recalculates and returns 60
This system guarantees a glitch-free execution model. When you update a signal that multiple computed signals depend on, Angular ensures that all dependencies are updated before any effects are run. This prevents intermediate, inconsistent states from becoming visible in your application.
An effect is an operation that runs in response to signal changes. It's perfect for side effects that don't produce a value, like logging analytics, synchronizing data with localStorage, or performing custom DOM rendering. Effects automatically track their dependencies and re-run whenever any of them change.
import { effect, signal, computed } from '@angular/core';
const username = signal('anonymous');
const isLoggedIn = computed(() => username() !== 'anonymous');
effect(() => {
console.log(`User is currently: ${username()}`);
// This effect now depends on the `username` signal.
});
// This will trigger the effect to log again.
username.set('Alice');
Effects also come with a powerful cleanup mechanism. When an effect is destroyed (for example, when its component is removed from the DOM), it can run a cleanup function. This is essential for preventing memory leaks by unsubscribing from observables or removing event listeners. You register a cleanup function by calling onCleanup within the effect's callback.
import { effect, signal } from '@angular/core';
effect((onCleanup) => {
const timer = setInterval(() => {
console.log('Timer ticked!');
}, 1000);
// Register a cleanup function to run when the effect is destroyed
onCleanup(() => {
clearInterval(timer);
console.log('Timer cleared.');
});
});
Finally, there are cases where you need to access a signal's value inside a reactive context without creating a dependency. The untracked function allows you to do just this. It's useful inside an effect when you want to read a value but not have the effect re-run when that specific value changes.
import { effect, signal, untracked } from '@angular/core';
const currentUser = signal('admin');
const counter = signal(0);
effect(() => {
// This effect depends on `counter` but not `currentUser`.
console.log(`Counter is ${counter()}`)
// Log the current user without creating a dependency.
untracked(() => {
console.log(`Action performed by: ${currentUser()}`)
});
});
// This change will NOT re-run the effect.
currentUser.set('guest');
// This change WILL re-run the effect.
counter.update(c => c + 1);
Understanding these three primitives and how they interact is the key to mastering Angular's modern, efficient reactivity model.