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
asyncpipe wrestling. - What this article covers:
signal(),computed(),effect(), and practical migration patterns fromBehaviorSubject.
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/unsubscribelifecycle. - 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 10Core 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
DestroyRefor the returnedEffectRef.
Signals in Components
- Replace
@Input()withinput()signal (Angular 17.1+) for computed children. - Replace
@Output()withoutput()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
BehaviorSubjectfor single values. - RxJS: async streams (HTTP, WebSocket, timer operators), complex operator chains (
switchMap,debounceTime), cross-component event buses. - Bridging:
toSignal(observable$)andtoObservable(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.
zonelessmode (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 (usecomputed()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
BehaviorSubjectservices, then migrate component inputs. - RxJS remains the right tool for async streams — use both where they fit.
- Next steps: try
@ngrx/signalsfor store-level signal state management.