In a zoneless Angular application, which notifications schedule change detection, and why does a field assigned inside setTimeout never render?
answer
- nobody watches the timer
- signal read by a template
- markForCheck, listener, setInput
- listener notifies before the await
basics
~20 sA zoneless Angular app checks only after a notification: a template-read signal is set, markForCheck() runs, a bound listener fires, setInput() is called, or a dirty view is attached. A setTimeout callback assigning a plain field sends none, so nothing refreshes.
solid answer
~50 sWith no zone.js, Angular's scheduler runs a check only when one of its own APIs notifies it: setting a signal that a template reads, `ChangeDetectorRef.markForCheck()` (which `AsyncPipe` calls on each emission), a bound template or host listener running, `ComponentRef.setInput()`, or attaching a view already marked dirty. Notifications in the same turn coalesce into one check, timed by racing `setTimeout` against `requestAnimationFrame`. A `setTimeout` callback that does `this.status = 'done'` produces none of these, because nothing patches the timer any more. The value may appear later only by accident, when some other notification triggers a check and the view happens to be refreshed; an `OnPush` view, the default since v22, is not refreshed then either, because it was never marked dirty. The fix is to hold the value in a signal and `set()` it, or call `markForCheck()` after the assignment.
go deeper
Remember that in a zoneless app templates update when a signal they read changes or a bound event fires, and that plain fields changed in timers do not show.
Name every notification source, explain coalescing into one check, and walk through why a plain field set in setTimeout or after an await never renders.
Recognise the intermittent symptom of a missing notification, explain why OnPush makes it deterministic, and pick signals, AsyncPipe or markForCheck as the fix.
Explain how to make missing notifications visible across a codebase: signal-first state conventions, lint rules and exhaustive dev-mode checks, rather than chasing bugs one component at a time.
## The scheduler needs to be told In a **zoneless** Angular application (the default since v21) there is no zone.js watching timers, promises and DOM events. Angular's internal change-detection scheduler instead waits for a **notification** from one of Angular's own APIs, then schedules one pass over the view tree. The notifications listed in the zoneless guide and in the `provideZonelessChangeDetection()` API docs are: - **A signal read in a template is set.** The template is a reactive consumer; writing the signal marks that view for refresh, marks its ancestors for traversal and notifies the scheduler. - **`ChangeDetectorRef.markForCheck()`** marks the view and its ancestors dirty. `AsyncPipe` calls it for every value its observable or promise delivers. - **A bound template or host listener runs**, such as `(click)="save()"` or a `host` listener. Angular marks the component's view dirty when the listener fires. - **`ComponentRef.setInput()`** gives a dynamically created component a new input. - **Attaching a view** that was marked dirty by one of the above; removing a view and registering a render hook also notify, but they refresh no template on their own. ## One check, not one per write Notifications are **coalesced**. If a click handler sets three signals, the scheduler records that a check is needed and ignores later notifications until that check has run. It times the check by racing a `setTimeout` against a `requestAnimationFrame` and running on whichever fires first, so updates normally land before the next paint. ## Why the timer case fails ```ts import {Component} from '@angular/core'; @Component({ selector: 'app-upload-status', template: `<p>Status: {{ status }}</p>`, }) export class UploadStatus { status = 'pending'; constructor() { setTimeout(() => (this.status = 'done'), 1000); } } ``` 1. The timer fires and assigns a plain class field. 2. Assigning a field calls no Angular API, so no notification is sent. 3. No check is scheduled, and the paragraph keeps showing `pending`. Under zone.js the same code worked, because the patched `setTimeout` told `NgZone` a task had finished and Angular ran a check. ## The "works by accident" trap The new value can still appear later if *something else* schedules a check, such as a click elsewhere on the page. Whether this component is refreshed then depends on its strategy and on what the other notification was: | Strategy | Refreshed by an unrelated check? | |---|---| | `Eager` (the old `Default`) | Often: a click or `markForCheck()` elsewhere refreshes the views up to the root, and Eager children of a refreshed view are refreshed too | | `OnPush` (the default since v22) | No: it was never marked dirty, so it is skipped | That is why a zoneless bug can look intermittent: a value that "shows up when I click somewhere else" is a missing notification. ## The listener-plus-await variant A bound listener notifies **when it fires**, not when the work it starts finishes: ```ts async reload() { // bound as (click)="reload()" const res = await fetch('/api/items'); this.items = await res.json(); // plain field: no notification } ``` The click schedules a check that runs before the response arrives; the later assignment notifies nobody. With `HttpClient` and `AsyncPipe`, or a signal `set()`, the second update is noticed. ## How to spot a missing notification - The value appears only after an unrelated interaction, such as a click or a keypress elsewhere on the page. - The value appears in tests that call `fixture.detectChanges()` but not in the running application. - The component's data is correct when logged to the console, but the template shows the previous value. - The code that changes the value runs inside a timer, a promise continuation, a WebSocket handler or a callback registered with a non-Angular library. Each of these points to the same root cause: state that the template reads was changed without any of Angular's notification APIs being involved. Development builds can make this visible systematically: `provideCheckNoChangesConfig({exhaustive: true, interval})` periodically re-checks views and throws when a binding changed without a notification. ## Fixes, in order of preference 1. **Hold template state in signals** and update it with `set()` or `update()`; the template read does the rest. 2. **Deliver streams through `AsyncPipe`** (or `toSignal`), which notify on every value. 3. **Call `ChangeDetectorRef.markForCheck()`** after mutating a plain field when the code cannot move to signals yet. What does **not** help: calling `NgZone.run()`. In a zoneless app the injected `NgZone` is a no-op implementation whose `run()` just invokes the function, so it schedules nothing.
- In a zoneless Angular app, does setting a signal that no template reads schedule a view refresh?Not for any view. The notification comes from the template being a reactive consumer of the signal; a signal read only in component code or an unrelated service has no view consumer to mark dirty. Computed signals and effects that depend on it still see the new value, and if a computed signal derived from it is read by a template, that template is refreshed.
- Why do reactive forms sometimes fail to update a zoneless Angular template?Calls such as `setValue()`, `patchValue()` or `FormArray.push()` update form state and emit the form's observables, but the zoneless guide notes they do not schedule component change detection themselves. A template that reads form state needs a notification: route the observable through `AsyncPipe`, call `markForCheck()`, or mirror the data into signals.
A zoneless app is a kitchen where the chef only cooks when a ticket is pinned to the rail. Writing a new order on a napkin (a plain field) is invisible until someone pins a ticket (a signal write or markForCheck).
saying these in an interview costs you the question
- A zoneless app still re-renders after every setTimeout callback.
- Every signal write refreshes every component in the application.
- Calling NgZone.run() makes a zoneless template update.
- An async click handler's awaited assignment is covered by the click.
- If it appears after clicking elsewhere, the component is correct.