Mark Lowel Montealto — Full Stack Developer & DevOps Engineer

Mark Lowel
Montealto

Full Stack Developer & DevOps Engineer

Blog

February 2025

·

2 min read

Angular Signals: Practical Reactive State in Angular 19+

Learn how Angular Signals replace RxJS for local state, reduce boilerplate, and enable fine-grained change detection in Angular 19+.

Introduction

  • Brief history: Angular's change detection evolved from Zone.js to optional signal-based tracking.
  • Why Signals matter: fine-grained reactivity, less subscription boilerplate, no more async pipe wrestling.
  • What this article covers: signal(), computed(), effect(), and practical migration patterns from BehaviorSubject.

What Is a Signal?

  • A Signal is a reactive primitive that tracks reads and notifies dependents on change.
  • Contrast with RxJS Observables: signals are synchronous and automatically tracked — no subscribe/unsubscribe lifecycle.
  • Anatomy of a signal:
import { signal, computed, effect } from '@angular/core';
 
const count = signal(0);
const doubled = computed(() => count() * 2);
 
effect(() => {
  console.log('Count changed:', count());
});
 
count.set(5); // triggers effect, doubles to 10

Core API Reference

signal<T>(initialValue) — writable signal

  • .set(value) — replace the value.
  • .update(fn) — derive next value from current.
  • .mutate(fn) — mutate an object/array in place (Angular 18+: prefer .update).

computed(() => expr) — read-only derived signal

  • Memoised: only re-runs when dependencies change.
  • Use for derived view model values (e.g., filtered lists, formatted strings).

effect(() => sideEffect) — reactive side effects

  • Runs once on creation, then whenever any read signal inside changes.
  • Must be created in an injection context (constructor, runInInjectionContext).
  • Clean up with DestroyRef or the returned EffectRef.

Signals in Components

  • Replace @Input() with input() signal (Angular 17.1+) for computed children.
  • Replace @Output() with output() for type-safe event emitters.
  • Template reads: Angular's template compiler tracks signal reads automatically — no | async.
@Component({
  selector: 'app-counter',
  template: `<p>{{ count() }} — {{ label() }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
  count = signal(0);
  label = input<string>('Counter');
  increment() { this.count.update(v => v + 1); }
}

Signals vs RxJS: When to Use Each

  • Signals: synchronous local/component state, computed view model, replacing BehaviorSubject for single values.
  • RxJS: async streams (HTTP, WebSocket, timer operators), complex operator chains (switchMap, debounceTime), cross-component event buses.
  • Bridging: toSignal(observable$) and toObservable(signal) from @angular/core/rxjs-interop.

Migrating from BehaviorSubject to Signal

  • Pattern: private _state$ = new BehaviorSubject(x)protected state = signal(x).
  • Update service methods: .next(val).set(val).
  • Template: remove | async, call signal directly.
  • Step-by-step migration checklist for a service.

Performance Impact: Signal-Based Change Detection

  • With ChangeDetectionStrategy.OnPush + signals, Angular skips subtrees where no signal changed.
  • Benchmark example: large list rendering — signal-based vs default zone-based.
  • zoneless mode (experimental in Angular 18): opt out of Zone.js entirely.

Common Pitfalls

  • Reading a signal outside an injection context (effect/computed) won't track dependencies.
  • Mutating objects directly without .update() won't trigger dependents.
  • Over-using effect() for derived values (use computed() instead).
  • Memory leaks: effects not cleaned up when component is destroyed.

Conclusion

  • Signals are Angular's recommended path for local reactive state going forward.
  • Start by replacing simple BehaviorSubject services, then migrate component inputs.
  • RxJS remains the right tool for async streams — use both where they fit.
  • Next steps: try @ngrx/signals for store-level signal state management.