In an OnPush Angular component, why does a click handler's field update render but the same update made later in a setTimeout callback does not?
answer
- the listener is wrapped
- marked before the statement runs
- view and ancestors up to root
- later callbacks are outside the wrapper
basics
~20 sAngular wraps every template listener so it marks the declaring view and its ancestors dirty before running the handler. A setTimeout callback runs outside that wrapper, so a plain-field change there leaves the OnPush view clean and unrendered.
solid answer
~40 sAngular does not register your statement directly; it registers a wrapper. Each time the event fires, the wrapper calls `markViewDirty` on the view that declared the listener (the child's own view when the listener sits on a component host), walking up through every ancestor to the root, and only then runs the statement. So any synchronous change the handler makes is picked up, even in an `OnPush` component. A `setTimeout`, promise or subscription callback runs later, outside the wrapper, so nothing marks the view. With zone.js a tick still happens but skips the clean `OnPush` view; in a zoneless app (the v21 default) nothing even schedules a pass. The fix is to hold that state in a signal, which notifies its template, or to call `ChangeDetectorRef.markForCheck()`.
code
ts · 17 linesimport { Component, signal } from '@angular/core';
@Component({
selector: 'app-saver',
template: `
<button type="button" (click)="save()">Save</button>
<span>{{ status() }}</span>
`,
})
export class Saver {
status = signal<'idle' | 'saving' | 'saved'>('idle');
save() {
this.status.set('saving');
setTimeout(() => this.status.set('saved'), 500); // signal write notifies the view
}
}go deeper
Recall that clicking in an OnPush component refreshes it, and that signals update the view wherever they are set.
Explain the listener wrapper: markViewDirty on the declaring view and its ancestors before the statement runs.
Diagnose stale async updates after an OnPush or zoneless migration and fix them with signals or markForCheck rather than detectChanges.
Plan the migration rule for a large codebase: which late-mutation patterns to convert to signals first, and how to find them.
## What Angular actually registers For `(click)="save()"`, the compiler does not hand `save()` to `addEventListener`. The runtime wraps it in a function (internally `wrapListenerIn_markDirtyAndPreventDefault`) that does three things on every event: 1. **Marks the view dirty.** It calls `markViewDirty` on the view where the listener is declared. If the listener sits on a component's host element, it starts from that component's own view instead. `markViewDirty` sets the dirty flags on that view and **every ancestor up to the root**, so `OnPush` parents on the path are also refreshed. 2. **Notifies the scheduler.** The same call notifies the change detection scheduler with the source `Listener`. In a zoneless app that notification schedules a change detection pass. In a zone.js app it is deliberately ignored, because the zone already triggers a tick after the event. 3. **Runs the statement**, then calls `preventDefault()` if it returned exactly `false`. The marking happens **before** your code runs and does not depend on what it does. A handler that changes nothing still marks the path dirty and, in zoneless mode, still schedules a pass. ## Why the later update is lost Since v22 a component without a `changeDetection` setting is `OnPush`. An `OnPush` view is refreshed when something has marked it, most commonly: an input changed, a listener in it fired, a signal it reads changed, or someone called `markForCheck()`. A plain field assignment is not on that list. ```ts @Component({ selector: 'app-saver', template: `<button (click)="save()">Save</button> {{ status }}`, }) export class Saver { status = 'idle'; save() { this.status = 'saving'; // rendered: the listener marked the view setTimeout(() => { this.status = 'saved'; // not rendered: nothing marks the view }, 500); } } ``` | Update happens in | View marked dirty? | Zone.js app | Zoneless app | |---|---|---|---| | The handler body, synchronously | yes, by the wrapper | rendered | rendered | | A later `setTimeout` / `then` / `subscribe` | no | tick runs but skips the clean view | no pass is scheduled at all | | A signal `.set()` anywhere | yes, through the template's signal consumer | rendered | rendered | The zone.js row explains why the bug often went unnoticed before: with the old `Eager` default, every tick checked every view, so the late change appeared anyway. Moving to `OnPush` (the v22 default) or to zoneless (the v21 default) exposes it. ## Fixes, in order of preference - **Hold the state in a signal.** `status = signal('idle')` and `this.status.set('saved')` in the callback. The template reads `status()`, so the view is marked for refresh and a pass is scheduled wherever the write happens. - **Keep the async value in the template's hands**, for example an observable read with the `async` pipe, which marks the view for check when a value arrives. - **Call `ChangeDetectorRef.markForCheck()`** after the late mutation. It does what the listener wrapper does: marks the view and its ancestors and notifies the scheduler. It is the right tool for code that cannot move to signals yet. - **Avoid `detectChanges()` as a reflex.** It runs a synchronous check of that subtree immediately; it works, but it hides the missing notification rather than fixing it. ## The other side: listeners that fire too often Because every template listener marks its path dirty and, when zoneless, schedules a pass, a high-frequency binding such as `(document:mousemove)` or `(window:scroll)` requests change detection on every event, even when the handler decides nothing changed. For those, register the listener imperatively (for example with `Renderer2.listen` or `addEventListener` in `afterNextRender`, cleaned up through `DestroyRef`) and write to a signal only when the value actually changes. That keeps the notification tied to real state changes instead of to raw input events. ## What to say in the interview - The listener wrapper, not the handler, marks the view dirty, and it does so up to the root. - Only the synchronous part of the handler is covered. - Signals and `markForCheck()` are the two ways to cover the asynchronous part.
- Which views does an Angular template listener mark dirty?The view where the listener is declared and every ancestor up to the root. When the listener sits on a component's host element, marking starts from that component's own view. Siblings and unrelated subtrees are not marked, so an `OnPush` sibling that reads a plain field the handler changed can still go stale.
- In a zoneless Angular app, does a template listener that changes no state still trigger change detection?Yes. The wrapper marks the view and notifies the scheduler before the handler runs, regardless of what the handler does. That is harmless for clicks but wasteful for high-frequency events such as mousemove or scroll, which is why those are often handled outside template bindings and only write to a signal when the value changes.
The listener is like a receptionist who stamps your ticket "needs review" the moment you walk in; anything you phone in after leaving arrives with no stamp, so nobody reviews it unless it goes through a channel that stamps it too.
saying these in an interview costs you the question
- OnPush components ignore their own event handlers until an input changes
- The handler's return value decides whether the view is marked dirty
- Only the component's own view is marked, never its ancestors
- Any async callback started inside a handler is still covered by the listener
- detectChanges is the standard fix for late updates in OnPush components