Angular spent roughly a decade with the same core model: NgModules to wire things together, Zone.js to know when to check the DOM for changes, and decorators like @Input() on plain class fields. Angular 17 through 20 replaced all three pieces — standalone components, signals, and function-based input()/output(). It's tempting to file that under "nicer syntax." The change detection story underneath is a different runtime, not a paint job.
What an Angular component looked like for a decade
Here's a small counter component the way it would have been written in Angular 15, the last major version before any of this started shipping — NgModule-based, decorator-driven, implicitly dependent on Zone.js to trigger change detection:
// counter.component.ts (Angular 15)
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="decrement()">-</button>
<span>{{ count }}</span>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
@Input() count = 0;
@Output() countChange = new EventEmitter<number>();
increment(): void {
this.count++;
this.countChange.emit(this.count);
}
decrement(): void {
this.count--;
this.countChange.emit(this.count);
}
}// counter.module.ts (Angular 15)
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CounterComponent } from './counter.component';
@NgModule({
declarations: [CounterComponent],
imports: [CommonModule],
exports: [CounterComponent],
})
export class CounterModule {}Two files to render one button. The component itself has no idea how or when its view gets refreshed — that's Zone.js's job, running quietly underneath everything.
The same component in current Angular
Angular 20 drops the module entirely (standalone is the default since Angular 17) and replaces the decorator-based @Input/@Output with signal-based input() and output() functions:
// counter.component.ts (Angular 20)
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = input(0);
countChange = output<number>();
increment(): void {
this.countChange.emit(this.count() + 1);
}
decrement(): void {
this.countChange.emit(this.count() - 1);
}
}One file. No NgModule, no EventEmitter import, and count is now a function you call — count() — instead of a field you read. That last detail looks cosmetic. It isn't.
The part that isn't just syntax: change detection
Classic Angular's change detection has one job: after literally any async event fires — a click, a setTimeout, an HTTP response — Zone.js notices, and Angular walks the entire component tree checking every binding to see if anything changed. Zone.js does this by monkey-patching browser APIs (setTimeout, addEventListener, Promise, XMLHttpRequest) so it can hook into anything that might have caused state to change. It works, but it means the cost of a single button click is proportional to the size of the whole app, not to what actually changed.
A signal is not just a reactive variable — it's a tracked read. When a template calls count() inside an interpolation, Angular records that this specific binding depends on that specific signal. When the signal's value changes, Angular knows exactly which bindings to update, without walking anything it doesn't need to. That's what makes zoneless Angular possible — once every reactive read goes through signals instead of ordinary field access, Zone.js has nothing left to watch for:
// main.ts (Angular 20, zoneless bootstrap)
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});Compare that to how every Angular app used to boot, with Zone.js loaded as an implicit, unconfigurable dependency before a single line of application code runs:
// main.ts (Angular 15)
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule);
// zone.js is imported as a side effect in polyfills.ts —
// change detection has no opt-out.Removing Zone.js isn't a bundle-size optimization. It removes an entire mechanism — global monkey-patching of async APIs — and replaces it with per-binding dependency tracking. That's a different execution model for the whole framework, not a different way to declare a component.
RxJS doesn't disappear, it gets a boundary
Existing RxJS-based code doesn't need a rewrite. @angular/core/rxjs-interop provides toSignal() and toObservable() so the two models can meet at an explicit boundary instead of everything being forced into one paradigm:
import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
export class UserProfileComponent {
private userService = inject(UserService);
// The HTTP call still returns an Observable; toSignal() reads its
// latest value into the signal graph so templates and computed()
// can depend on it like any other signal.
user = toSignal(this.userService.getCurrentUser(), {
initialValue: null,
});
}RxJS keeps doing what it's actually good at — composing async event streams, debouncing, retrying, combining multiple sources. Signals take over the part RxJS was always a slightly awkward fit for: plain synchronous UI state that a template reads directly.
The takeaway
It's easy to look at input() instead of @Input() and file the whole upgrade under API cleanup. The actual change is that Angular replaced a global, tree-wide change detection sweep triggered by monkey-patched async APIs with a fine-grained dependency graph where every reactive read is tracked individually. The new syntax is a visible side effect of that; it's not the upgrade itself. A codebase that migrates the decorators but keeps triggering state changes through plain mutable fields outside the signal graph gets the syntax without the actual benefit — the runtime model only pays off once state changes actually flow through signals end to end.