Signals Migration ๐
Angular Signals provide a simpler, synchronous reactivity model that replaces many patterns previously requiring RxJS. This guide shows you exactly how to migrate each pattern โ and when to keep RxJS.
๐ฏ Why Migrate to Signals?
Section titled โ๐ฏ Why Migrate to Signals?โSignals were introduced in Angular 16 and matured through v17โv21. They offer:
- Simpler mental model โ synchronous reads, no subscriptions to manage
- Fine-grained reactivity โ Angular tracks exactly which signals each component reads
- Less boilerplate โ no
subscribe,unsubscribe,takeUntilDestroyed, orasyncpipe - Better performance โ enables zoneless change detection with precise DOM updates
- Framework integration โ signal-based
input(),output(),viewChild(), andmodel()are first-class
โ๏ธ What to Migrate vs. What to Keep
Section titled โโ๏ธ What to Migrate vs. What to KeepโNot everything should become a signal. Use this decision framework:
| Use Case | Use Signals | Use RxJS |
|---|---|---|
| Component state (counters, flags, form values) | โ | |
| Derived/computed values | โ | |
| Parent โ child communication | โ
(input()) | |
| Child โ parent communication | โ
(output()) | |
| DOM queries | โ
(viewChild()) | |
| HTTP responses stored as state | โ
(resource()) | |
| Real-time WebSocket streams | โ | |
| Complex event orchestration (debounce, retry, race) | โ | |
| Router events | โ | |
| Multi-source merge/combineLatest | โ |
๐ Migration Patterns
Section titled โ๐ Migration PatternsโBehaviorSubject โ signal()
Section titled โBehaviorSubject โ signal()โThis is the most common migration. BehaviorSubjects that hold synchronous state map directly to signals.
Before:
import { BehaviorSubject } from 'rxjs';
export class CartService { private itemsSubject = new BehaviorSubject<CartItem[]>([]); items$ = this.itemsSubject.asObservable();
addItem(item: CartItem) { const current = this.itemsSubject.value; this.itemsSubject.next([...current, item]); }
get itemCount(): number { return this.itemsSubject.value.length; }}After:
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })export class CartService { readonly items = signal<CartItem[]>([]); readonly itemCount = computed(() => this.items().length);
addItem(item: CartItem) { this.items.update((current) => [...current, item]); }}Reading in a component is equally simplified:
// Before: subscribe or async pipe// <div>{{ items$ | async }}</div>
// After: just call the signal// <div>{{ cartService.items() }}</div>combineLatest + map โ computed()
Section titled โcombineLatest + map โ computed()โWhen you derive a value from multiple observables, computed() replaces combineLatest + map:
Before:
import { combineLatest, map } from 'rxjs';
class OrderSummaryComponent { total$ = combineLatest([this.cart.items$, this.pricing.tax$]).pipe( map(([items, taxRate]) => { const subtotal = items.reduce((sum, i) => sum + i.price, 0); return subtotal * (1 + taxRate); }), );}After:
import { computed } from '@angular/core';
class OrderSummaryComponent { private cart = inject(CartService); private pricing = inject(PricingService);
total = computed(() => { const subtotal = this.cart.items().reduce((sum, i) => sum + i.price, 0); return subtotal * (1 + this.pricing.tax()); });}Observable + async Pipe โ Signal in Template
Section titled โObservable + async Pipe โ Signal in TemplateโBefore:
@Component({ template: ` <div *ngIf="user$ | async as user"> <h1>{{ user.name }}</h1> <p>{{ user.email }}</p> </div> `,})export class ProfileComponent { user$ = this.userService.getCurrentUser(); constructor(private userService: UserService) {}}After with resource():
import { Component, inject, resource } from '@angular/core';import { UserService } from './user.service';
@Component({ template: ` @if (userResource.value(); as user) { <h1>{{ user.name }}</h1> <p>{{ user.email }}</p> } @if (userResource.isLoading()) { <p>Loading...</p> } @if (userResource.error()) { <p>Error loading user</p> } `,})export class ProfileComponent { private userService = inject(UserService);
userResource = resource({ loader: () => this.userService.getCurrentUser(), });}@Input() โ input()
Section titled โ@Input() โ input()โThe input() function replaces the @Input decorator with a signal-based API.
Before:
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
@Component({ selector: 'app-greeting', template: `<h1>Hello, {{ displayName }}!</h1>`,})export class GreetingComponent implements OnChanges { @Input() name = ''; @Input() title = ''; displayName = '';
ngOnChanges(changes: SimpleChanges) { this.displayName = this.title ? `${this.title} ${this.name}` : this.name; }}After:
import { Component, input, computed, ChangeDetectionStrategy } from '@angular/core';
@Component({ selector: 'app-greeting', template: `<h1>Hello, {{ displayName() }}!</h1>`, changeDetection: ChangeDetectionStrategy.OnPush,})export class GreetingComponent { name = input(''); title = input(''); displayName = computed(() => this.title() ? `${this.title()} ${this.name()}` : this.name(), );}Key differences:
input()creates a read-only signal โ call it with()to readinput.required<string>()for required inputs (replaces runtime checks)computed()replacesngOnChangesโ it recomputes automatically when inputs changeinput({ transform: numberAttribute })for input transforms
@Output() โ output()
Section titled โ@Output() โ output()โBefore:
import { Component, Output, EventEmitter } from '@angular/core';
@Component({ selector: 'app-search', template: `<input (keyup.enter)="onSearch($event)" />`,})export class SearchComponent { @Output() search = new EventEmitter<string>();
onSearch(event: Event) { const value = (event.target as HTMLInputElement).value; this.search.emit(value); }}After:
import { Component, output, ChangeDetectionStrategy } from '@angular/core';
@Component({ selector: 'app-search', template: `<input (keyup.enter)="onSearch($event)" />`, changeDetection: ChangeDetectionStrategy.OnPush,})export class SearchComponent { search = output<string>();
onSearch(event: Event) { const value = (event.target as HTMLInputElement).value; this.search.emit(value); }}The template binding stays the same: <app-search (search)="handleSearch($event)" />.
@ViewChild() โ viewChild()
Section titled โ@ViewChild() โ viewChild()โBefore:
import { Component, ViewChild, AfterViewInit, ElementRef } from '@angular/core';
@Component({ selector: 'app-canvas', template: `<canvas #myCanvas width="400" height="300"></canvas>`,})export class CanvasComponent implements AfterViewInit { @ViewChild('myCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
ngAfterViewInit() { const ctx = this.canvasRef.nativeElement.getContext('2d'); ctx?.fillRect(10, 10, 100, 100); }}After:
import { Component, viewChild, afterNextRender, ElementRef, ChangeDetectionStrategy,} from '@angular/core';
@Component({ selector: 'app-canvas', template: `<canvas #myCanvas width="400" height="300"></canvas>`, changeDetection: ChangeDetectionStrategy.OnPush,})export class CanvasComponent { canvasRef = viewChild.required<ElementRef<HTMLCanvasElement>>('myCanvas');
constructor() { afterNextRender(() => { const ctx = this.canvasRef().nativeElement.getContext('2d'); ctx?.fillRect(10, 10, 100, 100); }); }}Constructor DI โ inject()
Section titled โConstructor DI โ inject()โBefore:
import { Component } from '@angular/core';import { ActivatedRoute, Router } from '@angular/router';import { UserService } from './user.service';
@Component({ ... })export class UserDetailComponent { constructor( private route: ActivatedRoute, private router: Router, private userService: UserService, ) {}}After:
import { Component, inject } from '@angular/core';import { ActivatedRoute, Router } from '@angular/router';import { UserService } from './user.service';
@Component({ ... })export class UserDetailComponent { private route = inject(ActivatedRoute); private router = inject(Router); private userService = inject(UserService);}๐ Bridge Utilities: toSignal() and toObservable()
Section titled โ๐ Bridge Utilities: toSignal() and toObservable()โFor code that must interoperate between signals and RxJS, Angular provides bridge utilities.
toSignal() โ Observable โ Signal
Section titled โtoSignal() โ Observable โ SignalโConverts an Observable into a read-only signal. Ideal for consuming existing RxJS-based services:
import { Component, inject } from '@angular/core';import { toSignal } from '@angular/core/rxjs-interop';import { ActivatedRoute } from '@angular/router';import { map } from 'rxjs';
@Component({ selector: 'app-product', template: `<h1>Product ID: {{ productId() }}</h1>`,})export class ProductComponent { private route = inject(ActivatedRoute);
productId = toSignal( this.route.paramMap.pipe(map((params) => params.get('id'))), { initialValue: '' }, );}toObservable() โ Signal โ Observable
Section titled โtoObservable() โ Signal โ ObservableโConverts a signal into an Observable. Useful when a downstream API requires an Observable:
import { signal } from '@angular/core';import { toObservable } from '@angular/core/rxjs-interop';import { switchMap, debounceTime } from 'rxjs';
const searchTerm = signal('');
const results$ = toObservable(searchTerm).pipe( debounceTime(300), switchMap((term) => this.searchService.search(term)),);๐ Gradual Migration Strategy
Section titled โ๐ Gradual Migration StrategyโYou donโt have to migrate everything at once. Hereโs a phased approach:
Phase 1: New Code Uses Signals
Section titled โPhase 1: New Code Uses SignalsโWrite all new components and services with signals from day one. Use inject(), input(), output(), and signal().
Phase 2: Migrate Leaf Components
Section titled โPhase 2: Migrate Leaf ComponentsโStart with simple, isolated components that have minimal dependencies. These are easiest to migrate and lowest risk.
Phase 3: Migrate Shared Services
Section titled โPhase 3: Migrate Shared ServicesโConvert BehaviorSubject state in services to signal(). Use computed() for derived values. Components consuming these services get simpler automatically.
Phase 4: Migrate Complex Components
Section titled โPhase 4: Migrate Complex ComponentsโTackle components with ngOnChanges, lifecycle hooks, and complex subscription management last. Use computed(), effect(), and resource() to simplify them.
Phase 5: Use Automated Schematics
Section titled โPhase 5: Use Automated SchematicsโAngular CLI provides migration schematics for bulk changes:
# Migrate @Input to input()ng generate @angular/core:signal-input
# Migrate @Output to output()ng generate @angular/core:output
# Migrate @ViewChild/@ContentChild to viewChild()/contentChild()ng generate @angular/core:signal-queries
# Migrate constructor injection to inject()ng generate @angular/core:injectโ Migration Checklist
Section titled โโ Migration Checklistโ- All new components use
signal(),computed(),input(),output() -
BehaviorSubjectstate holders converted tosignal() -
combineLatest+mapderivations replaced withcomputed() -
@Inputdecorators migrated toinput()/input.required() -
@Outputdecorators migrated tooutput() -
@ViewChild/@ContentChildmigrated toviewChild()/contentChild() - Constructor injection replaced with
inject() -
ngOnChangesreplaced withcomputed()oreffect() -
asyncpipe usages replaced with signal reads orresource() - RxJS retained only where truly needed (streams, complex async orchestration)
- All tests pass after migration