Angular Zoneless Change Detection Internals: What Triggers a Render in 2026 [2026]
Zoneless Angular re-renders far less often than its Zone.js predecessor. That is a feature — but it means when a view does NOT update when you expect it to, you need to know exactly what would have triggered the update. This lesson is the internal model: dirty marking, the LView tree, what schedules a CD pass, and what markForCheck() and detectChanges() actually do now.
This is lesson 3.6, following lesson 3.5 on zoneless basics. After this lesson, debugging "why doesn't my view update?" becomes mechanical.
The five triggers — in order of frequency #
In a 2026 zoneless app, change detection runs for one of five reasons:
| Trigger | Frequency | Granularity |
|---|---|---|
| Signal write | Most common | Only views that read the signal |
| Template event handler | Every UI interaction | The host component + ancestors with OnPush implications |
| Output emission | Component-to-parent | Parent + its ancestors |
| Async pipe emission | Per Observable value | The view containing the pipe |
ApplicationRef.tick() |
Manual escape hatch | The entire app |
Note what is missing: setTimeout, fetch, addEventListener, RxJS .subscribe(...). None of those trigger CD on their own — only what they DO with the value matters (signal write → CD; plain field assignment → no CD).
Dirty marking — the bookkeeping #
Angular tracks per-component dirtiness via the LView (Logical View) structure — an internal data structure for each component instance. Every LView has a dirty bit.
When a CD-triggering event happens, Angular marks the relevant LViews dirty. On the next microtask, it walks the tree, re-running templates only for dirty views.
(parent) clean
├── ChildA dirty ← signal it reads just changed
│ └── GrandA clean
├── ChildB clean
└── ChildC dirty ← a template event fired here
└── GrandC dirty ← marked because ChildC is its parent and the path matters
Dirty marking is upward and inward — a dirty descendant marks its ancestors as needing CD checks (so they can pass updated inputs), but the actual template re-evaluation runs only on dirty views.
Signal-driven CD: how it works #
When you write to a signal, Angular's reactivity layer:
- Notifies every consumer (other computed signals + effects + template bindings)
- For each consumer that is INSIDE an LView (template binding, signal-based computed in a template), marks that LView dirty
- Schedules a CD pass on the next microtask (if not already scheduled)
In the CD pass:
- Walk the tree from the root
- For each component, if its LView is dirty OR an ancestor is, run change detection (re-evaluate template, propagate input changes, check bindings)
- Skip clean LViews entirely — no work happens
This is what makes zoneless fast: most of the tree is skipped on most updates. A signal change in one corner of the page doesn't re-render the rest.
Event-driven CD #
A template event handler — (click), (input), (submit) — automatically marks the host component dirty after the handler returns. Even if the handler did nothing render-relevant, the dirty mark fires (Angular is conservative — it cannot know what the handler did).
@Component({ template: '<button (click)="noop()">Click</button>' })
export class FooComponent {
noop() {} // does nothing, but the click still triggers a CD pass on FooComponent
}
This is the safety net for legacy code: even non-signal state mutations inside an event handler will be reflected because the host's CD runs after.
Output-driven CD #
When a child component calls this.someOutput.emit(value), Angular marks the LISTENING parent component dirty (the one with (someOutput)="handler($event)"). Same shape as the event-handler trigger.
async pipe — the bridge from RxJS #
The async pipe wraps an Observable's .subscribe(...). When the Observable emits, the pipe calls markForCheck() on its host LView, scheduling a CD pass.
This is how legacy RxJS code interoperates with zoneless: as long as the value reaches the template through async, the view updates. If you .subscribe(...) in code and assign to a plain field, the framework does not know — no CD trigger.
markForCheck() and detectChanges() — what they do now #
Two APIs from ChangeDetectorRef survive into zoneless:
import { ChangeDetectorRef, inject } from '@angular/core';
export class FooComponent {
private cdr = inject(ChangeDetectorRef);
someExternalCallback() {
this.cdr.markForCheck(); // marks this component dirty; CD runs at next microtask
this.cdr.detectChanges(); // SYNCHRONOUS — runs CD on this component RIGHT NOW
}
}
In zone-based Angular these were paired with OnPush to escape Zone.js's auto-check. In zoneless they are escape hatches for state mutations that don't go through signals or template events:
- A third-party callback writes to a plain field →
markForCheck()to schedule CD - A WebSocket message updates state →
markForCheck()after - A
setTimeoutfor animation timing → either use a signal, ormarkForCheck()after
In 2026 these calls should be rare. If you find yourself reaching for them constantly, switch the state to signals.
ApplicationRef.tick() — the nuclear option #
For cases where you cannot tell which component to mark dirty, ApplicationRef.tick() runs CD on the entire app:
import { ApplicationRef, inject } from '@angular/core';
export class GlobalEventService {
private appRef = inject(ApplicationRef);
handleGlobalEvent() {
// Update state from outside Angular's awareness
this.appRef.tick(); // re-render every dirty (and ancestor-dirty) LView
}
}
This is expensive (every component checked) and should be used sparingly. The presence of appRef.tick() calls in your code is a signal that you have state escaping Angular's reactivity awareness.
OnPush in zoneless — still relevant? #
In Zone.js Angular, OnPush was a perf optimization that skipped CD on unchanged inputs. In v22, OnPush is the default for new components and required mental model for everything.
In zoneless, OnPush is implicit — every LView's CD check requires either a dirty mark OR an input change. The same semantics, just no separate enum value to set.
A worked example: the clock #
The canonical zoneless gotcha:
// Broken in zoneless
export class ClockComponent {
time = new Date().toLocaleTimeString();
constructor() {
setInterval(() => {
this.time = new Date().toLocaleTimeString(); // ❌ no CD trigger
}, 1000);
}
}
The setInterval callback runs (the browser timer doesn't care about Zone.js), and this.time IS updated in memory. But Angular has no way to know it should re-render the view. The template shows the original time forever.
Three fixes:
// Fix 1: signal
export class ClockComponent {
time = signal(new Date().toLocaleTimeString());
constructor() {
setInterval(() => this.time.set(new Date().toLocaleTimeString()), 1000);
}
}
// Fix 2: manual markForCheck
export class ClockComponent {
private cdr = inject(ChangeDetectorRef);
time = new Date().toLocaleTimeString();
constructor() {
setInterval(() => {
this.time = new Date().toLocaleTimeString();
this.cdr.markForCheck();
}, 1000);
}
}
// Fix 3: rxjs + async pipe
export class ClockComponent {
time$ = interval(1000).pipe(map(() => new Date().toLocaleTimeString()));
}
// Template: <p>{{ time$ | async }}</p>
Fix 1 is the cleanest in 2026. Fix 3 is fine if you're already in an RxJS world. Fix 2 is the escape hatch when neither fits.
Debugging "my view doesn't update" #
The diagnostic flow:
- Is the state a signal? No → make it one. Yes → keep going.
- Are you writing to it via
.set()/.update()? No (you assigned the field directly) → use the signal API. - Is the template actually reading the signal? Use
{{ mySignal() }}not{{ mySignal }}. (The parentheses are required.) - Is the read inside an
@ifthat is currentlyfalse? The signal won't track until the condition changes. - Is there an
effect()reading a signal but not firing? Check that you created it in an injection context. - Last resort:
inject(ApplicationRef).tick()to confirm the rest of the system works.
9 of 10 "view doesn't update" bugs are step 1 or 2. The other 1 of 10 is step 4.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Signal write doesn't update view | Template reads {{ count }} instead of {{ count() }} |
Add the parentheses — signals are functions |
markForCheck() doesn't help |
Called outside a CD-triggering context AND component is already clean | Wrap in runInInjectionContext() or trigger a manual appRef.tick() |
| Some components update, others don't | Mixed signal and field state | Convert all rendered state to signals |
| Console warning "signal read outside reactive context" | Reading a signal in a non-reactive position (e.g., a service method called from a button) | The read still works; the warning is for perf-sensitive cases (effects, computeds) |
effect() doesn't run |
Not inside an injection context | Move to constructor or wrap with runInInjectionContext |
What's next #
Lesson 3.7 is the signal-vs-observable decision tree — when each is the right primitive. Lesson 3.8 closes Module 3 with linkedSignal() for dependent state.
Module 4 then picks up forms — signal forms first (lesson 4.1), reactive and template-driven forms after. The signal foundation you built in Module 3 carries through every later module.
Try it yourself #
Paste this into a component and watch CD trigger via different paths:
import { Component, signal, inject, ChangeDetectorRef, ApplicationRef } from '@angular/core';
@Component({
selector: 'app-cd-demo',
template: `
<p>Signal counter: {{ signalCount() }}</p>
<p>Field counter: {{ fieldCount }}</p>
<button (click)="signalCount.update(n => n + 1)">Bump signal</button>
<button (click)="fieldCount++">Bump field (via event)</button>
<button (click)="bumpFromTimer()">Bump field via setTimeout</button>
<button (click)="cdr.markForCheck(); fieldCount++">Bump field + markForCheck</button>
`,
})
export class CdDemoComponent {
signalCount = signal(0);
fieldCount = 0;
protected cdr = inject(ChangeDetectorRef);
bumpFromTimer() {
setTimeout(() => this.fieldCount++, 100);
// No CD trigger — view shows the OLD value
}
}
Click each button. Observe:
- Signal bump: view updates immediately
- Field bump via event: view updates (the click handler triggers CD)
- Field bump via setTimeout: view does NOT update
- Field bump + markForCheck: view updates
The demo makes the model concrete — exactly what each trigger does, exactly when.
get_best_practicesWrap the field in a signal — that’s the cleanest fix. If the library writes this.field = value via a callback, change the field declaration to field = signal(initial) and update the callback to this.field.set(value). If you cannot change the callback signature (the library does obj.field = x directly), call inject(ChangeDetectorRef).markForCheck() in a wrapper callback you DO control. Last resort: inject(ApplicationRef).tick() after the library’s callback returns.Lesson 3.7 picks up the signal-vs-observable decision tree.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


