Back to KB
Difficulty
Intermediate
Read Time
7 min

Angular signals and reactivity

By Codcompass TeamΒ·Β·7 min read

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:

  1. 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.
  2. 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.

ApproachChange Detection Cycles per UpdateUpdate GranularityBoilerplate OverheadMemory Footprint
Zone.js + OnPushO(N) componentsComponent-levelHigh (Subscriptions, markForCheck)High (Zone patches, async listeners)
RxJS + OnPushO(1) per streamStream-levelMedium (Operators, async pipe)Medium (Subscription graph)
Signals + ZonelessO(1) bindingsBinding-levelLow (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 computed signals 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, and toObservable() 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.js polyfills and remove them from polyfills.ts or angular.json.
  • Migrate State Management: Convert RxJS BehaviorSubject instances to signal() and computed() in services.
  • Update Component Inputs/Outputs: Replace @Input() and @Output() decorators with input(), output(), and model() functions.
  • Optimize Change Detection: Ensure components use changeDetection: ChangeDetectionStrategy.OnPush or rely on signal-based detection; remove manual markForCheck calls.
  • 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 toSignal and toObservable usage in hybrid components during migration.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Simple Component Statesignal()Lightweight, no subscriptions, direct template binding.Low (Bundle size, Memory)
Derived UI Datacomputed()Lazy evaluation, memoization, automatic dependency tracking.Low (CPU efficient)
Async Data StreamstoSignal()Interop with HTTP/WebSockets, automatic subscription management.Medium (Interop layer)
Complex Global StateSignal Store ServiceCentralized logic, type safety, testability without NgRx boilerplate.Medium (Architecture effort)
Legacy RxJS IntegrationtoObservable()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

  1. Create Zoneless Project: Run ng new my-signal-app --no-standalone or update an existing project. Remove zone.js from dependencies and polyfills.
  2. Configure Providers: In app.config.ts, import provideZoneChangeDetection and add it to providers with { eventCoalescing: true }.
  3. Create a Signal Service: Generate a service and define a signal for state and a computed for derived data. Export methods to update state.
  4. Consume in Component: Inject the service into a component. Bind signals directly in the template using {{ service.signal() }}. No async pipe or subscriptions required.
  5. 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