Angular Signals Pattern
Manage reactive state with Angular Signals — signal(), computed(), effect(), and toSignal() — for fine-grained, zone-free reactivity
When to Use
- Building new components in Angular 17+ that need reactive local state
- Replacing
BehaviorSubject+asyncpipe patterns with simpler signal-based state - Deriving display values from multiple state pieces without manual subscription management
- Bridging RxJS observables into signal-based components via
toSignal() - Preparing for zoneless change detection (Angular 18+)
Instructions
- Create mutable state with
signal<T>(initialValue). The returnedWritableSignal<T>exposes.set(),.update(), and.mutate()(arrays/objects). - Derive values with
computed(() => ...). Computed signals are lazy and memoized — they only recompute when their dependencies change. Never compute inside a template expression; usecomputed()instead. - Run side effects with
effect(() => ...). Effects re-run automatically when any signal they read changes. Clean up resources by returning a cleanup function or using theonCleanupcallback. - Convert an RxJS
Observableto a signal withtoSignal(obs$, { initialValue: ... }). This subscribes for you and unsubscribes on destroy. ProvideinitialValueto avoid theundefinedinitial state. - Convert a signal to an Observable with
toObservable(sig)when you need to compose it with RxJS operators. - Prefer signal inputs (
input()) over@Input()for new components — they integrate with the reactivity graph natively. - Do not call
.set()or.update()inside acomputed()— computed signals must be pure. - Wrap mutable signal state in a service when it needs to be shared across components.
import { Component, signal, computed, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ProductService } from './product.service';
@Component({
selector: 'app-cart',
template: `
<p>Items: {{ itemCount() }}</p>
<p>Total: {{ formattedTotal() }}</p>
<button (click)="addItem(selectedProduct())">Add</button>
`,
})
export class CartComponent {
private productService = inject(ProductService);
// Convert observable to signal — auto-unsubscribed on destroy
selectedProduct = toSignal(this.productService.selected$, {
initialValue: null,
});
items = signal<CartItem[]>([]);
itemCount = computed(() => this.items().length);
total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.qty, 0));
formattedTotal = computed(() =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(this.total())
);
constructor() {
// Side effect: persist cart to localStorage whenever items change
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
addItem(product: Product | null): void {
if (!product) return;
this.items.update((items) => [...items, { ...product, qty: 1 }]);
}
}
Details
Signal vs BehaviorSubject: A BehaviorSubject requires .subscribe(), .next(), and .unsubscribe() (or takeUntil). A WritableSignal has no subscription overhead and integrates with Angular's change detection graph directly. Signals also compose with computed() without the combineLatest ceremony required by observables.
Lazy computation: computed() is lazy and cached. If no consumer reads the computed signal, it never runs. If the dependencies haven't changed since last read, the cached value is returned without re-running the function. This makes computed signals safe to use in templates even for expensive derivations.
Effect cleanup: Effects that set up subscriptions, timers, or DOM listeners should clean up on re-run:
effect((onCleanup) => {
const id = setInterval(() => this.tick.update((t) => t + 1), 1000);
onCleanup(() => clearInterval(id));
});
toSignal guarantees: toSignal() must be called in an injection context (constructor or field initializer). It auto-subscribes and auto-unsubscribes using DestroyRef. The initialValue option avoids the T | undefined type widening; requireSync: true can be used when the observable is known to emit synchronously (e.g., BehaviorSubject).
Mutation helpers: For arrays and objects, use .update() to apply a pure transform:
this.items.update((list) => list.filter((i) => i.id !== removedId));
Avoid mutating in place then calling .set(this.items()) — signal equality checks use reference equality, so this won't trigger updates.
Zoneless change detection: Angular 18+ supports provideExperimentalZonelessChangeDetection(). With signals, components no longer need Zone.js to trigger change detection — signal writes schedule DOM updates directly. Adopting signals now future-proofs components for zoneless.
When to keep RxJS: Signals are not a replacement for RxJS when you need time-based operators (debounceTime, throttleTime), combination operators (combineLatest, forkJoin), or error handling (catchError, retry). Bridge with toSignal() / toObservable() at the boundary.
Source
https://angular.dev/guide/signals
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.