Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Adopting Angular signals in an existing app: a practical order How we bring signals into large Angular codebases without a rewrite: which APIs are stable, which migrations to run first, where RxJS stays, and the mistakes…
A typical case: an Angular application that has been upgraded steadily to a recent version, now uses standalone components, and still looks like Angular from five years ago inside. Every component has @Input setters, ngOnChanges, a BehaviorSubject or two, and templates full of the async pipe. The team has heard that signals are the future and wants to know where to begin without stopping feature work.
The short answer: adopt Angular signals from the leaves inwards. Run the official migrations for inputs, outputs and queries first, then move local component state and simple services to signal() and computed(). Keep RxJS for HTTP, debouncing and event streams, and bridge the two with toSignal() and toObservable(). Leave zoneless change detection until last.
This post assumes you are already on a recent major version and have standalone components. If you are not there yet, our posts on moving Angular 8 applications forward and on the standalone migration cover those steps.
What is stable to use in 2026 Signals arrived in stages, and older blog posts often describe APIs as experimental that no longer are. On current versions the picture is:
signal(), computed() and untracked() have been stable since Angular 17. Signal inputs (input(), input.required()), model(), output() and the signal queries (viewChild(), viewChildren(), contentChild(), contentChildren()) became stable in Angular 19. effect(), linkedSignal() and toSignal()/toObservable() from @angular/core/rxjs-interop are stable from Angular 20. Zoneless change detection was a developer preview in Angular 20.0 and became stable in 20.2; new projects on Angular 21 are created zoneless by default. resource() and httpResource() are newer. Check their status in the docs for your exact version before building core features on them. Signals and the older decorators work side by side in the same application, and even in the same component. That is what makes a gradual adoption possible.
Step one: inputs, outputs and queries These are the safest changes because Angular ships schematics for them, and they touch the edges of a component rather than its logic. We run them one feature folder at a time so each pull request stays reviewable.
# run per folder, commit, test, then move on
ng generate @angular/core:signal-input-migration --path src/app/finance
ng generate @angular/core:signal-queries-migration --path src/app/finance
ng generate @angular/core:output-migration --path src/app/finance
ng build --configuration production
ng test --watch=falseThe result looks like this. Note that templates now call the input as a function, and the schematic updates those references for you.
// before
export class InvoiceListComponent implements OnChanges {
@Input() studentId!: string;
@Output() selected = new EventEmitter<Invoice>();
@ViewChild('table') table?: ElementRef<HTMLTableElement>;
ngOnChanges(changes: SimpleChanges) {
if (changes['studentId']) { this.load(); }
}
}
// after
export class InvoiceListComponent {
studentId = input.required<string>();
selected = output<Invoice>();
table = viewChild<ElementRef<HTMLTableElement>>('table');
}The schematics skip inputs they cannot migrate safely, for example inputs written to from inside the component, or used in ways the analysis cannot follow. They report what they skipped. Read that output: it is usually a short list of the components with the most tangled state, and it tells you where the manual work is.
Two follow-ups are worth doing by hand. First, ngOnChanges logic that reacts to an input usually becomes a computed() or, for loading data, an rxjs-interop pipeline (shown below). Second, a component that copied an input into a local field so the user could edit it should use linkedSignal(), which resets whenever the input changes but can still be set locally.
export class AddressFormComponent {
address = input.required<Address>();
draft = linkedSignal(() => ({ ...this.address() }));
setCity(city: string) {
this.draft.update(d => ({ ...d, city }));
}
}Step two: component state and simple services Next, look for state that is synchronous and owned by one place: a selected tab, a filter, a cart, a form wizard step. These are often BehaviorSubjects only because that was the reactive tool available. They convert cleanly.
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly lines = this.items.asReadonly();
readonly count = computed(() => this.items().length);
readonly total = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
);
add(item: CartItem) {
this.items.update(list => [...list, item]);
}
remove(id: string) {
this.items.update(list => list.filter(i => i.id !== id));
}
}Keep the writable signal private and expose read-only views. In templates, {{ cart.count() }} replaces {{ count$ | async }}, and there is no subscription to manage. If existing consumers still expect an Observable, expose toObservable(this.lines) alongside for a release or two, then remove it once callers have moved.
Where RxJS stays, and how to bridge Signals hold a current value. RxJS models events over time. They are not competitors, and trying to remove RxJS completely from an existing app is usually wasted effort. We keep RxJS for HTTP calls, debounced search, websockets, retries and anything that needs switchMap-style cancellation, and convert to a signal at the point where the template reads it.
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
export class StudentSearchComponent {
private api = inject(StudentApi);
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => (q.length < 2 ? of([]) : this.api.search(q)))
),
{ initialValue: [] as Student[] }
);
}Both functions need an injection context, so call them in a field initialiser or the constructor. Calling toSignal() inside ngOnInit or a click handler throws, unless you pass an injector explicitly. toSignal() subscribes immediately and unsubscribes when the component is destroyed, which also removes a class of leaks from older code.
Mistakes we see in partly migrated code Using effect() to copy one signal into another. If a value can be derived, use computed(). If it is derived but locally editable, use linkedSignal(). Keep effect() for side effects that leave Angular: logging, localStorage, a third-party chart or map library. Mutating arrays and objects in place. Signals compare with Object.is by default, so items().push(x) changes nothing as far as Angular can tell. Always produce a new reference with set() or update(). Reading signals you did not mean to track inside computed() or effect(). Wrap incidental reads in untracked() so the dependency list stays what you intended. Forgetting initialValue on toSignal(). Without it the type includes undefined, and templates fill with optional chaining. Converting a component to signals but leaving it on default change detection. Signals pair naturally with ChangeDetectionStrategy.OnPush, because a signal read in the template marks that component for checking when it changes. Rewriting tests last. Signal inputs cannot be assigned directly in a test; use fixture.componentRef.setInput('studentId', 'S-104') instead, and update tests in the same pull request as the component. Zoneless change detection comes last Removing zone.js is where signals pay off most, through less change detection work and smaller bundles, but it is also the step most likely to break an existing app. Code that updates a plain field after a setTimeout, a promise or a third-party callback relied on zone.js to trigger a refresh. Without it, the screen simply does not update.
The safe sequence is to get most components onto OnPush with signal or async pipe bindings first. OnPush components already behave close to how zoneless Angular expects. Then switch the application over in a staging build:
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient()
]
};
// then remove "zone.js" from the build polyfills and
// "zone.js/testing" from the test polyfills in angular.json,
// and uninstall the zone.js package once nothing imports itThen click through every screen, paying attention to anything driven by timers, drag and drop, maps, editors and other libraries that call back outside Angular. Fixes are usually small: put the value in a signal, or call markForCheck() on the ChangeDetectorRef where a signal is not practical.
A practical order of work Confirm the app builds and tests pass on a current Angular version with standalone components. Run the input, query and output migrations per feature folder, reviewing what each one skipped. Replace ngOnChanges logic with computed() or linkedSignal() where the schematic left it behind. Move synchronous component and service state from BehaviorSubject to private signals with read-only views. Keep RxJS for async streams and convert at the edge with toSignal() and toObservable(). Turn on OnPush component by component, with tests updated in the same change. Try zoneless in a staging build only once most of the app is signal or OnPush driven. None of these steps needs a feature freeze. Each one is a set of small pull requests that can ship between normal work, and the application keeps running throughout. If the codebase is large, shared across several apps, or has thin test coverage, it is worth getting a second pair of eyes on the plan before the first schematic runs.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789