TLDR: Everything that stabilized in Angular 19–21: zoneless change detection, signal inputs/outputs/model, linkedSignal, resource/httpResource, incremental hydration, and @let. Plus a prioritized migration path for legacy codebases — do it in this order or you'll end up going zoneless before your components are signal-ready.
Angular 19–21 delivered on the promise that started with v16. What began as "signals are coming" is now a complete model: signal inputs, signal outputs, linked signals, resource-based async, incremental hydration, and — finally — a path out of Zone.js entirely.
I've been running Angular 21 in production and experimenting with everything the team shipped over the last year. Here's what's genuinely worth adopting and what I'd prioritize on a legacy codebase.
Zone.js is optional now — and you should start planning the exit
The biggest architectural shift in 2026 Angular: zoneless change detection is production-ready. You opt in with one provider:
// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // replaces Zone.js entirely
provideRouter(routes),
provideHttpClient(),
],
};
Remove zone.js from your polyfills in angular.json and you've dropped a ~35 kB dependency from your bundle. More importantly, change detection now runs only when signals notify the framework — not after every setTimeout, XHR, or event listener in the entire app.
I once spent two days tracking down a performance regression that turned out to be a third-party analytics library calling setInterval every 100ms. Zone.js saw every tick and ran change detection across thousands of components. With zoneless Angular, that category of bug disappears entirely.
The catch: your codebase needs to be signal-aware before you flip the switch. Components that rely on Zone.js triggering CD on async operations will silently stop updating. Migrate incrementally — signals first, zoneless last.
The complete signal component model
Angular's signal APIs stabilized across v19–21. If you're still writing @Input() and @Output() decorators in new components, you're writing 2023 Angular.
Signal inputs — replace @Input()
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-post-card',
template: `
<article>
<h2>{{ post().title }}</h2>
<p class="text-sm text-zinc-500">{{ readTimeLabel() }}</p>
</article>
`,
})
export class PostCardComponent {
post = input.required<Post>();
// Derived from input — updates automatically when post changes
readTimeLabel = computed(() => `${this.post().readTime} · ${this.post().author}`);
}
input.required<T>() — TypeScript knows it's always defined, no ! hacks needed. input<T>(defaultValue) for optional inputs with a fallback. Both are signals you can use in computed() chains.
Signal outputs — replace @Output() + EventEmitter
import { Component, output } from '@angular/core';
@Component({ ... })
export class SearchComponent {
searched = output<string>();
onSearch(query: string) {
this.searched.emit(query);
}
}
No more new EventEmitter<string>(). The output() function is cleaner and works without Zone.js.
Two-way binding with model()
import { Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
template: `
<button (click)="value.set(!value())">
{{ value() ? 'On' : 'Off' }}
</button>
`,
})
export class ToggleComponent {
value = model(false); // writable signal with two-way binding
}
// Parent usage:
// <app-toggle [(value)]="darkMode" />
model() replaces the @Input() + @Output() + EventEmitter combo for two-way binding. One declaration instead of three.
linkedSignal() — writable derived state
The gap computed() couldn't fill: you need a signal that starts derived from another value, but can also be written to independently.
Classic example — a selected tab that resets when the data changes but can also be set by the user:
import { signal, linkedSignal } from '@angular/core';
export class TabsComponent {
posts = signal<Post[]>([]);
// Resets to the first post whenever posts changes,
// but can also be set directly by user interaction
selectedPost = linkedSignal(() => this.posts()[0] ?? null);
select(post: Post) {
this.selectedPost.set(post); // direct write — works fine
}
}
Without linkedSignal(), you'd reach for effect() to reset selectedPost when posts changes — which is semantically wrong (effects are for side effects, not state derivation). linkedSignal() is the right tool here.
resource() and httpResource() — async data that's signal-native
This is the API I was most waiting for. Async data fetching that integrates cleanly into the signal graph, with loading/error states built in.
resource() for custom async logic
import { Component, signal, resource } from '@angular/core';
@Component({
selector: 'app-post',
template: `
@if (postResource.isLoading()) {
<app-skeleton />
} @else if (postResource.error()) {
<p class="text-red-500">Failed to load post.</p>
} @else if (postResource.value(); as post) {
<article>
<h1>{{ post.title }}</h1>
<div [innerHTML]="post.content"></div>
</article>
}
`,
})
export class PostComponent {
slug = input.required<string>();
postResource = resource({
request: () => ({ slug: this.slug() }),
loader: ({ request }) =>
fetch(`/posts/${request.slug}.md`).then(r => r.text()),
});
}
When slug changes (route param update), postResource automatically re-fetches. Loading/error/value states are signals — no manual isLoading flag management.
httpResource() for HTTP specifically
import { Component, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
@Component({ ... })
export class UserProfileComponent {
userId = input.required<string>();
userResource = httpResource<User>(
() => `/api/users/${this.userId()}`
);
}
httpResource() wraps the HttpClient with the same resource API. Gets you request cancellation, error handling, and loading state automatically. The URL is reactive — change userId, get a fresh request.
Incremental hydration — @defer blocks on demand
Angular 19 extended @defer beyond lazy loading to hydration. Server-rendered @defer blocks stay as inert HTML until triggered — they don't ship or execute JavaScript until the user actually needs them.
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration()),
provideRouter(routes),
provideHttpClient(),
],
};
<!-- This section is SSR'd as plain HTML.
Angular JS for it only loads when it scrolls into view. -->
@defer (on viewport; hydrate on viewport) {
<app-comments [postId]="post().slug" />
} @placeholder {
<div class="comments-skeleton h-48"></div>
}
The difference from regular @defer: with just @defer, the block is excluded from SSR entirely — you get a blank placeholder until JS loads. With hydrate on viewport, the server renders real HTML that users see immediately, and Angular only hydrates it (loads the JS, attaches event listeners) when it's needed.
For content-heavy pages with interactive sections below the fold — comments, recommendation widgets, social embeds — this cuts JavaScript execution on initial load dramatically.
@let — template variable declarations
Small QoL addition in v18 that I reach for constantly now:
<!-- Before: awkward async pipe aliasing -->
@if (userResource.value(); as user) {
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
<!-- had to use user.X everywhere, nested inside the if -->
}
<!-- After: @let for reusable template variables -->
@let user = userResource.value();
@let isAdmin = user?.roles?.includes('admin') ?? false;
@if (user) {
<h1>{{ user.name }}</h1>
<p class="text-sm">{{ user.email }}</p>
@if (isAdmin) {
<app-admin-panel />
}
}
@let variables are re-evaluated on every change detection run, so they're always in sync. No more deeply nested @if chains just to alias a signal value.
Deferrable views: Still the highest-leverage bundle optimization
Nothing changed here from 2025, and it's still the highest single-ROI optimization on content-heavy apps. If you haven't audited your app for @defer opportunities, do that before anything else.
@defer (on viewport) {
<app-analytics-chart [data]="reportData()" />
} @placeholder {
<div class="chart-skeleton h-64 rounded-xl bg-zinc-100 dark:bg-zinc-800"></div>
} @loading (minimum 300ms) {
<app-spinner />
} @error {
<p>Chart failed to load.</p>
}
@defer (on interaction(triggerEl)) {
<app-comment-thread [postId]="id" />
}
@defer (on idle; prefetch on hover(triggerEl)) {
<app-recommendations />
}
I applied @defer to below-the-fold sections of a dashboard and cut initial bundle size by 38%. The sections loaded in as users scrolled, and nobody noticed — because they loaded before they were needed.
The @for track requirement still trips people up
Worth repeating for anyone still on structural directives:
<!-- Old: easy to skip trackBy and get full list re-renders -->
<li *ngFor="let post of posts; trackBy: trackBySlug">{{ post.title }}</li>
<!-- New: track is required — no way to forget it -->
@for (post of posts(); track post.slug) {
<li>{{ post.title }}</li>
} @empty {
<li>No posts yet.</li>
}
The migration codemod handles this: ng generate @angular/core:control-flow.
Migration path for a 2026 codebase
If you're upgrading an existing Angular app, here's the order I'd approach it — prioritized by impact and safety:
1. Add ChangeDetectionStrategy.OnPush everywhere. Still the safest first step with the most immediate impact. Pair with async pipe for observables you haven't migrated yet.
2. Run the control flow codemod. ng generate @angular/core:control-flow converts *ngFor/*ngIf/*ngSwitch to the new syntax. Zero risk, free perf wins on lists.
3. Migrate inputs/outputs in new components. Use input(), output(), model() for any component you write from now on. Backfill older ones when you're already in them.
4. Replace async data patterns with resource()/httpResource(). Find your services that do Observable + isLoading flag + error flag patterns. Each one is a candidate.
5. Audit your bundle with ng build --stats-json. Feed the stats to Webpack Bundle Analyzer or source-map-explorer. Move large eager dependencies behind @defer or lazy routes.
6. Enable incremental hydration on SSR apps. Add withIncrementalHydration() to your hydration provider and start adding hydrate on viewport to appropriate @defer blocks.
7. Go zoneless last. Only after your components are signal-driven. Flip provideZonelessChangeDetection(), drop zone.js from polyfills, fix whatever breaks.
The path is gradual and each step is independently valuable. You don't need to do all seven to get a meaningfully faster app.