Angular signals and reactivity
Current Situation Analysis
Angular's reactivity model has historically relied on zone.js to intercept asynchronous operations and trigger a "pull-based" change detection cycle. This approach requires the framework to traverse the component tree to verify bindings, resulting in performance costs that scale with the size of the application rather than the scope of the change. While ChangeDetectionStrategy.OnPush optimizes this by skipping subtrees when input references remain unchanged, it does not eliminate the overhead of tree traversal or address the cognitive burden of manual markForCheck calls and complex RxJS stream management.
The industry pain point is twofold:
- Performance Degradation at Scale: In enterprise applications with deep component trees, frequent state updates trigger unnecessary change detection cycles. Benchmarks indicate that zone.js-based detection can consume significant CPU cycles on updates that affect only a single DOM node.
- State Management Complexity: Developers often conflate asynchronous data streams with reactive state. This leads to over-engineered solutions where simple component state is wrapped in heavy RxJS observables, increasing bundle size and maintenance cost without proportional benefit.
Many teams overlook the distinction between stream-based reactivity and value-based reactivity. RxJS excels at handling asynchronous event streams, but using it for synchronous state management introduces boilerplate and subscription management overhead. Angular signals address this by introducing a fine-grained, push-based reactive primitive that decouples state mutation from the rendering engine, allowing for O(1) updates to specific bindings without tree traversal.
WOW Moment: Key Findings
The shift to signal-based reactivity fundamentally alters the performance profile of Angular applications. The following data comparison highlights the efficiency gains when migrating from traditional zone.js/OnPush patterns to a signal-driven architecture.
| Approach | Change Detection Cycles per Update | Update Granularity | Boilerplate Overhead | Memory Footprint |
|---|---|---|---|---|
| Zone.js + OnPush | O(N) components | Component-level | High (Subscriptions, markForCheck) | High (Zone patches, async listeners) |
| RxJS + OnPush | O(1) per stream | Stream-level | Medium (Operators, async pipe) | Medium (Subscription graph) |
| Signals + Zoneless | O(1) bindings | Binding-level | Low (Get/Set operations) | Low (Signal graph nodes) |
Why this matters: Signals reduce change detection work from traversing the component tree to updating only the specific DOM nodes bound to changed signals. This results in predictable performance regardless of application size. The elimination of zone.js removes the monkey-patching overhead on browser APIs, further reducing runtime cost and improving initial load performance.
Core Solution
Implementing Angular signals requires a shift in how state is defined, derived, and consumed. Signals are reactive primitives that notify consumers only when their value changes.
Step-by-Step Implementation
1. Define Reactive State
Use signal() for mutable state and computed() for derived state. Signals are functions; reading a value requires invoking the signal (), and writing requires .set() or .update().
import { signal, computed, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
// Mutable state
private readonly items = signal<string[]>([]);
private readonly discountCode = signal<string | null>(null);
// Derived state: computed signals are lazy and memoized
readonly itemCount = computed(() => this.items().length);
readonly totalPrice = computed(() => {
const base = this.items().reduce((acc, item) => acc + this.getPrice(item), 0);
const discount = this.discountCode() === 'SAVE10' ? 0.1 : 0;
return base * (1 - discount);
});
// Methods to update state
addItem(item: string) {
this.items.update(items => [...items, item]);
}
setDiscount(code: string) {
this.discountCode.set(code);
}
private getPrice(item: string): number {
// Mock price lookup
return 10;
}
}
2. Handle Side Effects with effect()
Effects run when signal dependencies change. They are intended for side effects like logging, analytics, or DOM manipulation, not for updating other signals.
import { effect, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
constructor(private cartService: CartService) {
// Effect tracks signal reads automatically
effect(() => {
const count = this.cartService.itemCount();
if (count > 0) {
console.log(`Cart updated: ${count} items`);
// Send to analytics API
}
});
}
}
3. Component Integration
Signals integrate directly with templates. Angular's signal-based change detection automatically subscribes to signals used in the template.
import { Component, input, output, model } from '@angular/core';
@Component({
selector: 'app-cart-summary',
standalone: true,
template: `
<div>Items: {{ cart.itemCount() }}</div>
<div>Total: ${{ cart.totalPrice() }}</div>
<!-- Signal inputs and outputs -->
<app-promo-input
[code]="promoCode()"
(codeChange)="onPromoChange($event)"
/>
` }) export class CartSummaryComponent { // Signal inputs replace @Input readonly promoCode = input<string>('');
// Signal outputs replace @Output readonly promoChange = output<string>();
constructor(public cart: CartService) {}
onPromoChange(code: string) { this.cart.setDiscount(code); } }
#### 4. Zoneless Configuration
To fully leverage signals, disable zone.js. This requires configuring the application to use signal-based change detection.
```typescript
// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
// Disable zone.js, enable signal-based change detection
provideZoneChangeDetection({ eventCoalescing: true })
]
};
Architecture Decisions
- Signal Stores vs. Services: For complex state, encapsulate signals within a service acting as a store. Expose only
computedsignals and methods to the UI to maintain unidirectional data flow. - Immutability: Signals do not perform deep equality checks. Always replace objects/arrays rather than mutating properties. Use
update()to create new references. - RxJS Interoperability: Use
toSignal()to convert observables to signals for template binding, andtoObservable()to convert signals to observables for legacy APIs. Avoid mixing both patterns within the same data flow.
Pitfall Guide
1. Signal Mutation Trap
Mistake: Mutating properties of an object inside a signal without calling set or update.
// β BAD: Change detection will not trigger
const user = signal({ name: 'Alice' });
user().name = 'Bob';
Fix: Always use .set() or .update() to create a new reference.
// β
GOOD
user.update(u => ({ ...u, name: 'Bob' }));
2. Infinite Effect Loops
Mistake: Writing to a signal inside an effect that reads the same signal.
// β BAD: Infinite loop
effect(() => {
const count = counter();
if (count < 10) {
counter.set(count + 1); // Triggers effect again
}
});
Fix: Use computed for derived state. Reserve effect for side effects that do not feed back into the reactive graph.
3. Reading Signals Outside Reactive Contexts
Mistake: Reading a signal in a non-reactive function or lifecycle hook without establishing a dependency.
Fix: Ensure signals are read within templates, computed, effect, or functions called by them. Reading a signal in a plain function returns the current value but does not establish reactivity.
4. Overusing Effects for Derived State
Mistake: Using effect to update a signal based on another signal.
// β BAD: Imperative and error-prone
effect(() => {
fullName.set(`${firstName()} ${lastName()}`);
});
Fix: Use computed. It is lazy, memoized, and automatically tracks dependencies.
// β
GOOD
const fullName = computed(() => `${firstName()} ${lastName()}`);
5. Ignoring Injector Context
Mistake: Calling effect() or inject() outside of an injection context.
Fix: effect() must be called within an injection context (e.g., component constructor, service constructor, or runInInjectionContext). Use DestroyRef to manage effect lifecycle if needed.
6. Mixing RxJS and Signals Blindly
Mistake: Subscribing to observables inside effects to update signals.
Fix: Use toSignal() for converting streams to signals. This handles subscription lifecycle and error handling automatically.
const dataSignal = toSignal(dataStream$, { initialValue: [] });
7. Performance Cost of Excessive Computed Dependencies
Mistake: Creating computed signals that depend on large arrays or frequently changing signals unnecessarily.
Fix: Computed signals re-evaluate when dependencies change. Optimize by breaking down complex computations or using input() transforms to compute values at the component boundary.
Production Bundle
Action Checklist
- Audit Zone Dependencies: Identify all
zone.jspolyfills and remove them frompolyfills.tsorangular.json. - Migrate State Management: Convert RxJS
BehaviorSubjectinstances tosignal()andcomputed()in services. - Update Component Inputs/Outputs: Replace
@Input()and@Output()decorators withinput(),output(), andmodel()functions. - Optimize Change Detection: Ensure components use
changeDetection: ChangeDetectionStrategy.OnPushor rely on signal-based detection; remove manualmarkForCheckcalls. - Refactor Derived State: Replace effects that update signals with
computed()signals. - Verify Immutability: Audit all signal updates to ensure objects and arrays are replaced, not mutated.
- Configure Zoneless: Add
provideZoneChangeDetection({ eventCoalescing: true })to app providers. - Test Interoperability: Validate
toSignalandtoObservableusage in hybrid components during migration.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Simple Component State | signal() | Lightweight, no subscriptions, direct template binding. | Low (Bundle size, Memory) |
| Derived UI Data | computed() | Lazy evaluation, memoization, automatic dependency tracking. | Low (CPU efficient) |
| Async Data Streams | toSignal() | Interop with HTTP/WebSockets, automatic subscription management. | Medium (Interop layer) |
| Complex Global State | Signal Store Service | Centralized logic, type safety, testability without NgRx boilerplate. | Medium (Architecture effort) |
| Legacy RxJS Integration | toObservable() | Maintain compatibility with existing libraries and APIs. | Low (Interop layer) |
| Side Effects (Logging/Analytics) | effect() | Declarative side effects with automatic dependency tracking. | Low (Runtime) |
Configuration Template
angular.json (Remove Zone.js Polyfill):
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}
app.config.ts:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter([])
]
};
signal-store.service.ts Template:
import { Injectable, signal, computed } from '@angular/core';
export interface State {
users: User[];
loading: boolean;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly state = signal<State>({
users: [],
loading: false,
error: null
});
// Selectors
readonly users = computed(() => this.state().users);
readonly isLoading = computed(() => this.state().loading);
readonly error = computed(() => this.state().error);
// Actions
setLoading(isLoading: boolean) {
this.state.update(s => ({ ...s, loading: isLoading }));
}
setUsers(users: User[]) {
this.state.update(s => ({ ...s, users, loading: false }));
}
}
Quick Start Guide
- Create Zoneless Project:
Run
ng new my-signal-app --no-standaloneor update an existing project. Removezone.jsfrom dependencies and polyfills. - Configure Providers:
In
app.config.ts, importprovideZoneChangeDetectionand add it to providers with{ eventCoalescing: true }. - Create a Signal Service:
Generate a service and define a
signalfor state and acomputedfor derived data. Export methods to update state. - Consume in Component:
Inject the service into a component. Bind signals directly in the template using
{{ service.signal() }}. Noasyncpipe or subscriptions required. - Run and Verify:
Execute
ng serve. Open DevTools. Verify that updates to signals trigger DOM changes without zone.js overhead. Use Angular DevTools to inspect the signal graph.
Sources
- β’ ai-generated
