Change Detection Issues
“The view does not update”
Section titled ““The view does not update””This is the single most reported Angular problem. Work through the causes in this order.
Cause 1 — Mutating instead of replacing (with OnPush)
Section titled “Cause 1 — Mutating instead of replacing (with OnPush)”Symptom You push into an array or set a property and nothing changes on screen.
// ❌ Same reference → OnPush sees no changeaddItem(item: Item): void { this.items.push(item);}Fix
// ✅ New referenceaddItem(item: Item): void { this.items = [...this.items, item];}
// ✅ Better: signalsreadonly items = signal<Item[]>([]);addItem(item: Item): void { this.items.update((current) => [...current, item]);}Prevention
Type your state as readonly Item[] and Readonly<T> so the compiler rejects mutations.
Cause 2 — The update happens outside Angular’s knowledge
Section titled “Cause 2 — The update happens outside Angular’s knowledge”Symptom
Data arrives from a WebSocket, a third-party SDK, setTimeout inside a library, or a browser event registered outside Angular. Nothing re-renders.
Diagnosis Clicking anywhere on the page makes the value appear — that confirms change detection was simply not triggered.
Fix (modern) — write to a signal. Signals notify Angular regardless of zones:
export class TickerComponent { readonly price = signal(0);
constructor() { afterNextRender(() => { socket.on('price', (value: number) => this.price.set(value)); }); }}Fix (legacy, Zone.js only)
private readonly zone = inject(NgZone);
socket.on('price', (value) => { this.zone.run(() => (this.price = value));});Prevention Any integration boundary (SDK, socket, worker) should write into signals, never into plain fields.
Cause 3 — OnPush parent blocking a child
Section titled “Cause 3 — OnPush parent blocking a child”Symptom The child has its own state but only refreshes when you interact with it.
Cause
With OnPush, a component is checked only when: an input reference changes, an event fires inside it, an async pipe emits, a signal it reads changes, or it is marked dirty.
Fix
private readonly cdr = inject(ChangeDetectorRef);this.cdr.markForCheck(); // marks this component and its ancestors dirtyPrevention
Prefer signals over markForCheck(). A signal read in a template marks the component automatically.
Cause 4 — Reading a signal outside the template
Section titled “Cause 4 — Reading a signal outside the template”// ❌ Reads the value once, never reactsreadonly total = this.cart().length;
// ✅ Reactivereadonly total = computed(() => this.cart().length);async pipe vs manual subscription
Section titled “async pipe vs manual subscription”Symptom Data loads but the view only updates on the first emission, or the component leaks.
Cause
.subscribe() with a field assignment does not mark an OnPush component dirty.
Fix
<!-- ✅ The async pipe calls markForCheck() for you and unsubscribes -->@if (user$ | async; as user) { <app-user-card [user]="user" />}Or convert to a signal:
readonly user = toSignal(this.userService.user$, { initialValue: null });See RxJS interop.
Zoneless migration issues
Section titled “Zoneless migration issues”Symptom: everything stops updating after removing Zone.js
Section titled “Symptom: everything stops updating after removing Zone.js”Cause
Zoneless mode only triggers change detection on: signal writes, template event listeners, markForCheck(), async pipe emissions and router navigation. Plain field mutations no longer work.
Diagnosis Enable zoneless gradually with the hybrid scheduler first:
provideZoneChangeDetection({ eventCoalescing: true })Then move to:
provideZonelessChangeDetection()Fix checklist
- All component state is expressed with signals.
- No
setTimeout/setIntervalmutating fields directly. - Third-party callbacks write to signals.
- All templates use
asyncpipe or signals, never raw fields updated insubscribe. - Tests use
await fixture.whenStable()instead offixture.detectChanges()where possible. -
NgZone.onStable/NgZone.isStableusages removed.
Prevention Write new code as if zoneless were already enabled, even if it is not. It costs nothing and makes the migration a configuration change.
Performance: too many change detection cycles
Section titled “Performance: too many change detection cycles”Symptom The Angular DevTools Profiler shows hundreds of cycles per second or very long bars.
Common causes
| Cause | Fix |
|---|---|
| Method calls in templates | Replace with computed() |
Pipes that are not pure | Make them pure or precompute |
mousemove / scroll listeners | @HostListener with throttling, or run outside Angular |
Missing OnPush | Enable it everywhere |
Large @for without track | Add a stable track |
afterRender doing layout reads | Move to afterNextRender |
Running noisy listeners outside Angular:
private readonly zone = inject(NgZone);
constructor() { afterNextRender(() => { this.zone.runOutsideAngular(() => { window.addEventListener('scroll', this.onScroll, { passive: true }); }); });}Diagnosing with the Profiler
Section titled “Diagnosing with the Profiler”- Open Angular DevTools → Profiler → Record.
- Reproduce the interaction.
- Look at the flame chart:
- Wide bars → an expensive component. Check for getters and heavy pipes.
- Many bars → too many triggers. Check event listeners and timers.
- Bars for components that should not change → missing
OnPush.
- Click a bar to see why the component was checked.