Adopting Angular Signals in an Existing Codebase: What Actually Changes
Angular Signals replace zone.js change detection with a pull-based model. This post shows the concrete API shifts, RxJS interop, and the experimental zoneless path — plus the equality and testing gotchas you'll hit in the first week.
21 Apr 2026, 15:09 UTC

The problem: change detection that runs too often
Angular's zone.js-based change detection has always been a blunt instrument. Every async event — click, timer, HTTP response — triggers a full tree check. OnPush helps, but you still reason about immutable references and markForCheck. Signals, stable since Angular 17, replace that with a pull model: a template reads a signal, the framework tracks the dependency, and only that signal's consumers update when its value changes. No zone, no dirty checking, no ChangeDetectorRef.
The decision isn't "should we rewrite everything." It's "where does the payoff justify the friction?" This post walks through the concrete API shifts, the RxJS interop reality, and the one experimental path that lets you drop zone.js entirely.
Core APIs you'll use daily
Three functions cover most local state:
signal(initialValue)— writable, returns a getter function() => Tand a.set()/.update()API.computed(() => derived)— read-only, memoized, re-runs only when its signal dependencies change.effect(() => sideEffect)— runs synchronously after each change batch; use for logging, DOM measurements, or syncing to localStorage.
All live in @angular/core. No extra package, no schematic required.
Signal-based inputs: the component contract changes
Since v17.1 you can declare inputs as signals:
import { Component, input, computed } from '@angular/core';
@Component({ selector: 'app-user-card', template: `...` })
export class UserCard {
// required, no decorator
user = input.required<{ name: string; role: string }>();
// optional with default
compact = input(false);
// derived, updates only when user() changes
displayName = computed(() => this.compact() ? this.user().name[0] : this.user().name);
}
In the parent template you pass values exactly as before: <app-user-card [user]="currentUser" [compact]="true">. The difference: UserCard now reacts to user changes without OnPush or @Input() setter boilerplate. The signal is read in the template (user()), so Angular subscribes to that specific signal.
Worked example: replacing a BehaviorSubject with toSignal
Suppose a service exposes a user profile as a BehaviorSubject. The old component subscribes via async pipe:
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
private subject = new BehaviorSubject<User | null>(null);
user$ = this.subject.asObservable();
// ... methods that call subject.next()
}
// profile.component.ts (before)
@Component({ template: '<div>{{ (user$ | async)?.name }}</div>' })
export class ProfileComponent {
user$ = inject(UserService).user$;
}
Migrate to a signal in the component:
// profile.component.ts (after)
import { toSignal } from '@angular/core/rxjs-interop';
@Component({ template: '<div>{{ user()?.name }}</div>' })
export class ProfileComponent {
private userService = inject(UserService);
// toSignal requires an injection context (component, directive, pipe)
user = toSignal(this.userService.user$, { initialValue: null });
}
What changed:
- Template reads
user()— a synchronous getter. - No
asyncpipe, no subscription management. initialValueavoidsundefinedduring the first microtask.- If you need an observable again (e.g., for an operator chain),
toObservable(user)gives you one.
Caveat: toSignal throws outside an injection context. In a plain class or utility function you must pass a DestroyRef manually: toSignal(source$, { injector: inject(Injector) }).
Trade-offs you'll hit in the first week
Equality is Object.is by default
signal([1,2]) then .set([1,2]) does not notify consumers — same reference, same value. You must either create a new array (.set([...arr, 3])) or provide a custom equality function: signal(initial, { equal: (a,b) => a.id === b.id }). This catches teams used to OnPush reference checks.
Effects run synchronously
effect(() => { this.http.post(...).subscribe() }) blocks the UI thread until the effect finishes. Defer heavy work: effect(() => { queueMicrotask(() => this.http.post(...).subscribe()) }) or move side-effects to an event handler.
Zone.js is still there unless you opt into zoneless
Angular 18+ offers provideExperimentalZonelessChangeDetection() in app.config.ts. It removes zone.js from the bundle if your entire component tree uses signals (no async pipe, no @Input(), no EventEmitter). As of 2026 this is still experimental — not recommended for production. You can measure the bundle delta locally: build with and without the provider and compare main.js size.
Testing utilities still lean on zone.js
TestBed.configureTestingModule runs in a zone. For signal-only components you can use fakeAsync/tick or simply call fixture.componentRef.setInput('user', newValue) and fixture.detectChanges() — the signal updates synchronously, no tick needed.
How to start without a migration plan
- Pick one leaf component that uses
OnPushand a couple of@Input()s. - Change the inputs to
input()/model(). - Replace any local
BehaviorSubjectorcombineLatestwithsignal/computed/toSignal. - Run the component in isolation (Storybook, Cypress component test, or a dedicated route) and verify the template updates when you call
.set()in DevTools console. - Check bundle size:
ng build --stats-jsonand inspectmain.jsfor zone.js removal (only if you enabled zoneless).
If the component works, you've validated the mental model. Expand outward. The rest of the app keeps running on zone.js — no big bang required.
Bottom line
Signals give you fine-grained reactivity with zero runtime overhead for the parts that adopt them. The APIs are small, the interop with RxJS is bidirectional, and the migration path is genuinely incremental. The real friction points — equality semantics, synchronous effects, zoneless maturity — are visible in the first few components you convert. Try one, measure, then decide how far to go.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.