Modern Angular: Signals, Standalone Components and SSR · Routing, State, Performance and SSR
Rendering Performance: track, @defer and Zoneless Change Detection
Keep rendering fast and correct with proper @for tracking, lazy @defer blocks, and signal-driven zoneless change detection.
What you'll build
Three small experiments: a list where tracking by $index moves a typed note onto the wrong item after reordering (and tracking by id doesn't), a @defer block whose placeholder, loading and complete states are stepped through in a test, and a zoneless component showing that a plain field change isn't rendered until a signal change schedules change detection.
The idea
trackidentifies items; with$index, DOM nodes (and their state, like input values) stay at positions instead of following items.@defer (on viewport)(alsoon idle,on interaction,on timer,when cond) loads a block's components lazily in a separate chunk, with@placeholder,@loadingand@errorblocks.- Angular apps are zoneless by default in current versions: change detection runs when signals used in templates change, on template events, and on
markForCheck, not after every timer or promise. Signals make updates explicit and cheap.
The code
import { Component, signal } from "@angular/core";
import { DeferBlockBehavior, DeferBlockState } from "@angular/core/testing";
import { TestBed } from "@angular/core/testing";
import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing";
@Component({
selector: "app-list",
template: `
@for (item of items(); track trackBy === "id" ? item.id : $index) {
<label>{{ item.name }} <input /></label>
}
`,
})
class NotesList {
trackBy: "id" | "index" = "id";
items = signal([{ id: 1, name: "Mug" }, { id: 2, name: "Teapot" }]);
}
@Component({
selector: "app-product",
template: `
<h1>Teapot</h1>
@defer (on viewport) { <section>Reviews: 4.8 stars</section> }
@placeholder { <p>Reviews placeholder</p> }
@loading { <p>Loading reviews...</p> }
`,
})
class ProductPage {}
@Component({ selector: "app-clock", template: `<p>plain {{ plain }}, signal {{ ticks() }}</p>` })
class Clock {
plain = 0;
ticks = signal(0);
}
TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
for (const trackBy of ["index", "id"] as const) {
const fixture = TestBed.createComponent(NotesList);
fixture.componentInstance.trackBy = trackBy;
await fixture.whenStable();
fixture.nativeElement.querySelector("input").value = "gift for mum"; // typed next to "Mug"
fixture.componentInstance.items.update((list) => [...list].reverse());
await fixture.whenStable();
const rows = [...fixture.nativeElement.querySelectorAll("label")] as HTMLElement[];
console.log(`track by ${trackBy}: ` + rows.map((r) => `${r.textContent!.trim()}="${r.querySelector("input")!.value}"`).join(", "));
}
TestBed.resetTestingModule();
TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual });
const page = TestBed.createComponent(ProductPage);
await page.whenStable();
const [reviews] = await page.getDeferBlocks();
for (const state of [DeferBlockState.Placeholder, DeferBlockState.Loading, DeferBlockState.Complete]) {
await reviews.render(state);
const parts = [...page.nativeElement.querySelectorAll("h1, p, section")].map((n: Element) => n.textContent!.trim());
console.log(`defer ${DeferBlockState[state]}: ${parts.join(" | ")}`);
}
const clock = TestBed.createComponent(Clock);
await clock.whenStable();
clock.componentInstance.plain = 5; // not a signal: nothing scheduled
await new Promise((resolve) => setTimeout(resolve, 20));
console.log(`zoneless, plain field changed: ${clock.nativeElement.textContent}`);
clock.componentInstance.ticks.set(1); // signal: schedules rendering
await clock.whenStable();
console.log(`zoneless, signal changed: ${clock.nativeElement.textContent}`);
Output
track by index: Teapot="gift for mum", Mug="" track by id: Teapot="", Mug="gift for mum" defer Placeholder: Teapot | Reviews placeholder defer Loading: Teapot | Loading reviews... defer Complete: Teapot | Reviews: 4.8 stars zoneless, plain field changed: plain 0, signal 0 zoneless, signal changed: plain 5, signal 1
Run this on your computer — see “Get Started” for setup.
With $index, the note typed next to "Mug" stays in the first row, which now shows "Teapot". The defer block shows its placeholder until its trigger fires. The plain field change only appears once a signal change triggers rendering.
Common mistakes
track $index(or tracking whole objects that get recreated) for lists that reorder or reload.- Deferring above-the-fold content, delaying what users see first; defer below-the-fold or interaction-only UI.
- Updating plain fields from timers or callbacks in zoneless apps and wondering why the view doesn't change; use signals.
- Heavy computations in template expressions; use
computed.
Key points
- Track by stable IDs so DOM state follows items.
- @defer splits rarely needed UI into lazy chunks with placeholder and loading states.
- Zoneless change detection runs when signals change: keep view state in signals.
Further reading: Deferred loading with @defer · Zoneless · @for track
Practice questions
What goes wrong with track $index on a reorderable list?
Answer
DOM nodes stay attached to positions, so per-row state such as typed input values ends up next to a different item after reordering.
What does @defer (on viewport) do?
Answer
It lazily loads the block's dependencies in a separate chunk and renders it when its placeholder scrolls into view, showing the @placeholder until then.
What triggers change detection in a zoneless Angular app?
Answer
Changes to signals read in templates, template event handlers, markForCheck, and a few framework APIs; not arbitrary timers or promises.
Open in the app to mark lessons complete, take notes and track your progress.