Angular Signals: Trading Global Change Detection for Granular Reactivity
Angular Signals replace Zone.js global change detection with a granular reactive graph. Learn how signal(), computed(), and effect() work, where they fit alongside RxJS, and how to verify the performance shift.
16 Jul 2026, 03:08 UTC

Large Angular applications often hit a wall where every user interaction triggers a full change detection sweep across the component tree. Zone.js enables this by monkey-patching asynchronous browser APIs so Angular knows when to check for updates. The cost grows with the tree: a single click can force Angular to evaluate hundreds of components, even when only one value changed. The useful takeaway is that Angular Signals replace that global sweep with a dependency graph. When a signal changes, only the DOM nodes bound to it update. Over time, this granularity also opens a path to removing Zone.js entirely, shrinking bundle size and speeding up bootstrap.
The Zone.js Tax
Zone.js wraps native async operations—setTimeout, Promise, DOM event listeners—so that after each one completes, Angular runs change detection. In a small app this is invisible. In a large one, the framework must walk the component tree to find what changed. OnPush change detection narrows this to component boundaries, but it still checks entire templates. The granularity is per-component, not per-binding.
Signals, Computed Values, and Effects
Angular Signals introduce a reactive graph at the value level. A signal() wraps a value and notifies dependents when it changes. A computed() derives a new value from other signals; it is memoized, meaning it only re-evaluates when its specific dependencies change. An effect() runs a side effect whenever the signals it reads change, synchronized with the framework's rendering cycle.
Because the graph is explicit, Angular can update only the interpolations or bindings that depend on a changed signal. There is no need to diff an entire component template when unrelated state mutates.
Worked example: filtering without full-tree checks
Consider an inventory component that displays a total count and a filtered list of low-stock items. With classic property binding, changing the threshold would require Angular to re-check the whole template. With signals, only the derived values that depend on the threshold re-evaluate.
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-inventory',
template: `
<p>Total items: {{ total() }}</p>
<p>Low stock: {{ lowStockCount() }}</p>
`
})
export class InventoryComponent {
items = signal([
{ name: 'Widget', stock: 5 },
{ name: 'Gadget', stock: 12 }
]);
threshold = signal(10);
total = computed(() => this.items().length);
lowStockCount = computed(() =>
this.items().filter(i => i.stock < this.threshold()).length
);
constructor() {
effect(() => {
// Side effect synchronized with the reactive graph
console.log('Low stock count is now', this.lowStockCount());
});
}
}
In this example, calling threshold.set(8) causes lowStockCount to re-run because it reads threshold(). The total computed does not re-run, because its only dependency is items. You can verify this by placing a console.log inside each computed factory and observing which logs appear when you change the threshold. These APIs assume a recent Angular version where signals are stable; confirm compatibility with your project's Angular release.
Where Signals Stop: Async Streams and Effect Discipline
Signals are synchronous state containers. They are not a replacement for RxJS when you need to compose complex asynchronous event streams—HTTP retries, WebSocket message merging, debounced search, or cancellation. For those, Observables remain the right tool. Angular provides interop helpers such as toSignal() to convert Observables into signals, but mixing the two paradigms carelessly can create redundant reactivity loops where a signal update triggers an Observable subscription that updates the signal again.
Another limitation is architectural. effect() is easy to overuse. When business logic lives inside effects, the reactive graph becomes a web of side effects that is hard to trace. Prefer pure computed() values for derived state, and reserve effect() for genuinely external work: logging, manual DOM manipulation, or synchronizing with non-Angular libraries.
A Practical Migration Path
You do not need to rewrite an application to adopt signals. Start by wrapping local UI state—toggles, form drafts, selected IDs—in signal(). Replace getter-based derived properties with computed() so memoization is explicit. Hold off on effects until you have a concrete side-effect requirement.
To measure the impact, use the Chrome DevTools performance profiler. Record an interaction before and after converting a component to signals, then compare the number of change detection cycles. Angular DevTools can also visualize the signal graph and confirm dependency tracking. If a computed value re-runs when unrelated state changes, memoization is not working as expected—check that you are reading signals through their function call syntax inside the computed factory.
Removing Zone.js entirely is the end state of this transition, not the first step. It requires that every component in the bootstrap path uses signal-based inputs and outputs so Angular never needs the global async hooks. Until then, Zone.js can coexist with signals, giving you granular updates without a risky big-bang migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.