You have enabled provideZonelessChangeDetection() in your app.config.ts. You have modernized your components to use Signals. Yet, there are pockets of your application—specifically inside setTimeout, setInterval, or third-party library callbacks—where state changes are ignored by the render cycle. The data model updates, but the DOM remains stale until you click a button or resize the window.
This is not a bug in Angular 18+. It is a rigorous enforcement of reactivity that Zone.js previously allowed you to ignore.
The Root Cause: Zone.js Masked Mutation Bugs
To understand the fix, you must understand what you removed.
Zone.js worked by monkey-patching browser APIs (Promise, setTimeout, addEventListener). Whenever any async macro-task completed, Zone.js assumed something might have changed and triggered a top-down check (ApplicationRef.tick()).
This architecture had a side effect: It hid reactivity violations.
If you mutated an object inside a Signal (without updating the Signal reference) or updated a plain class property inside an async callback, Zone.js triggered a view refresh anyway. The template interpolation checked the value, saw the mutation, and updated the DOM.
In a Zoneless world, Angular relies on the ChangeDetectionScheduler. This scheduler only schedules a render (tick) when:
- A Signal notifies a consumer (Template or Effect) that it has changed.
markForCheck()is explicitly called.- An Event Handler managed by Angular's renderer is triggered.
If your async operation relies on legacy patterns—specifically object mutation or unmonitored async callbacks—the scheduler never receives the notification.
The Scenario
Consider a typical Enterprise dashboard using a generic data grid. We are simulating an async data refresh (or a WebSocket event) using setTimeout.
The Broken Code
This code works perfectly with Zone.js but fails silently with provideZonelessChangeDetection().
@Component({
selector: 'app-user-status',
standalone: true,
template: `
<div class="status-card">
<h3>User: {{ user().name }}</h3>
<!-- The UI will NOT update this status in Zoneless -->
<span class="badge">{{ user().status }}</span>
</div>
`
})
export class UserStatusComponent implements OnInit {
// A Signal holding an object
user = signal({ name: 'Architecture Team', status: 'Offline' });
ngOnInit() {
// Simulating an external async event (WebSocket, Timeout, 3rd Party Lib)
setTimeout(() => {
const currentUser = this.user();
// ❌ ANTI-PATTERN: Direct Mutation
// Zone.js would catch this macro-task and trigger a tick.
// Zoneless scheduler sees no Signal write, so it ignores it.
currentUser.status = 'Online';
console.log('Status updated to Online', this.user().status);
// Console says 'Online', DOM says 'Offline'.
}, 2000);
}
}
The Fix: Immutability and Explicit Notification
To fix this, we must align with the core principle of Zoneless: Reference Equality.
Angular Signals determine change based on strict equality (===). If you mutate a property of an object held within a Signal, the object reference remains the same. The Signal does not notify its dependents, and the Zoneless scheduler does not schedule a tick.
Solution 1: Immutable Updates (Recommended)
Use the .update() method and the spread syntax to create a new object reference. This notifies the scheduler immediately.
ngOnInit() {
setTimeout(() => {
// ✅ CORRECT: Create new reference via spread
this.user.update(current => ({
...current,
status: 'Online'
}));
}, 2000);
}
Solution 2: Managing Unaware Third-Party Libraries
Sometimes you cannot use Signals directly. You might be using a legacy Charting library (e.g., Highcharts, D3) or a Data Grid that exposes an event hook where you simply receive data and cannot control the internal state mechanism.
If the library runs entirely outside of Angular's context (and therefore doesn't use Angular's wrapper for event listeners), you might need to manually bridge the gap.
import { Component, ChangeDetectorRef, inject, signal, OnInit } from '@angular/core';
@Component({
// ... selector/template
})
export class ChartWrapperComponent implements OnInit {
private cdr = inject(ChangeDetectorRef);
chartData = signal<number[]>([]);
ngOnInit() {
// Assume 'LegacyChartLib' is a global or imported 3rd party class
const chart = new LegacyChartLib('#chart-container');
chart.on('dataPointClick', (event: any) => {
// Even if we update the signal, if this callback runs in a
// weird context (like a canvas worker proxy), we might need safety.
this.chartData.update(d => [...d, event.value]);
// ⚠️ EDGE CASE:
// In 99% of Zoneless cases, the Signal update above is enough.
// However, if the UI still stalls, it implies the callback is
// executing in a microtask starvation loop or detached context.
// Force the scheduler explicitly for non-reactive external sources
this.cdr.markForCheck();
});
}
}
Why This Works: The ChangeDetectionScheduler
In Angular 18+, the ChangeDetectionScheduler listens to the "Producer/Consumer" graph.
- The Producer: Your
userSignal. - The Consumer: The Template (specifically, the text interpolation node).
When you call this.user.update(...), the Signal acts as a Producer. It checks if the new value is different from the old value.
- Mutation (Bad):
RefA === RefA. No change detected. No notification sent. - Immutable (Good):
RefA !== RefB. Change detected.
Once the change is detected, the Signal marks all its consumers (the Template) as "Dirty". The ChangeDetectionScheduler observes this dirty status and schedules an ApplicationRef.tick (usually coalesced via requestAnimationFrame or microtasks).
Without Zone.js patching setTimeout, there is no automatic "safety net" tick. You are now responsible for ensuring your Signals actually fire their notifications.
Conclusion
If your view isn't updating in a Zoneless application, do not reach for detectChanges() immediately. Check your mutations.
- Are you mutating an object inside a Signal? Use spread syntax/immutability.
- Are you updating a plain class property? Refactor to a Signal.
- Are you in a detached third-party callback? Use
markForCheck().
Zoneless Angular doesn't break your app; it exposes the places where your app was breaking reactivity rules all along.