Removing zone.js reduces bundle size and improves stack trace clarity, but it does not remove the fundamental laws of Angular's Unidirectional Data Flow. In fact, relying purely on Signals can expose race conditions in your reactive graph that Zone.js previously masked (or managed differently).
One specific edge case in enterprise applications involves "Layout Thrashing" scenarios: a child component renders, calculates a dimension based on its content, and attempts to update a shared Signal that drives the parent's layout. In a Zoneless environment, doing this naïvely within an effect triggers ExpressionChangedAfterItHasBeenCheckedError (NG0100) or, worse, an infinite change detection loop.
The Root Cause: Signal Graph Instability
In the Zone.js era, this error occurred because a lifecycle hook (like ngAfterViewInit) updated a property that had already been bound to the DOM in the parent view. Angular had finished checking the parent, stepped into the child, and the child invalidated the parent's clean state immediately.
In Zoneless Angular, the mechanism is different but the outcome is the same.
- Render Phase: Angular processes the template. It reads
Signal A. - Effect Phase: The render reads the signal, possibly triggering an
effect()registered in a child component. - Violation: If that
effect()synchronously writes toSignal B, andSignal Bis also read in the current view (or derivesSignal A), the reactive graph becomes unstable. The view is dirty before the paint completes.
Angular prevents writing to signals inside computed, but effect is allowed to write. However, when an effect runs as a consequence of the current render pass and writes back to the state graph immediately, you violate the unidirectional flow.
The Scenario
Consider a Data Grid where the container needs to know the total width of dynamically sized columns to determine if a horizontal scrollbar is required.
- Parent: Reads
totalWidthsignal to apply styles. - Child (Column): Renders content, measures its own DOM width, and updates the
totalWidthvia a Service or Output.
If the Child updates the signal synchronously during the render process, Angular throws NG0100.
The Solution: afterNextRender Phase Scheduling
The fix requires breaking the synchronous link between the render pass and the state update. While setTimeout works, it is imprecise and triggers macro-task scheduling which can delay Time to Interactive (TTI).
The architectural solution in Angular 18+ is afterNextRender. This function schedules the callback to run only after the DOM update and Paint are complete.
The Implementation
Here is a complete, Zoneless-ready implementation using Angular 18 features.
import {
Component,
Injectable,
Signal,
WritableSignal,
signal,
computed,
effect,
ElementRef,
viewChild,
inject,
afterNextRender,
ChangeDetectionStrategy
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
// ----------------------------------------------------------------
// 1. Grid State Service
// Acts as the single source of truth for column metrics.
// ----------------------------------------------------------------
@Injectable({ providedIn: 'root' })
class GridStateService {
// Map of ColumnID -> Width
private columnWidths: WritableSignal<Record<string, number>> = signal({});
// Computed total width - The Parent reads this.
readonly totalWidth: Signal<number> = computed(() => {
const widths = Object.values(this.columnWidths());
return widths.reduce((sum, w) => sum + w, 0);
});
updateColumnWidth(id: string, width: number) {
this.columnWidths.update((prev) => ({
...prev,
[id]: width
}));
}
}
// ----------------------------------------------------------------
// 2. Child Component: Dynamic Column
// Measures itself and reports back to the service.
// ----------------------------------------------------------------
@Component({
selector: 'app-grid-column',
standalone: true,
template: `
<div #contentWrapper class="p-4 border border-gray-300 whitespace-nowrap">
<ng-content></ng-content>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GridColumnComponent {
private el = inject(ElementRef);
private state = inject(GridStateService);
// New signal-based view query
private wrapper = viewChild.required<ElementRef<HTMLDivElement>>('contentWrapper');
private readonly colId = crypto.randomUUID();
constructor() {
// CRITICAL: We do NOT use standard effects here to update state
// based on DOM measurements. That causes NG0100.
// We use afterNextRender to ensure we measure and update
// strictly AFTER the browser has painted the current frame.
afterNextRender(() => {
this.measureAndReport();
});
}
private measureAndReport() {
const nativeEl = this.wrapper().nativeElement;
const width = nativeEl.getBoundingClientRect().width;
// This write happens in the "Read" phase of the NEXT cycle,
// not the "Check" phase of the CURRENT cycle.
this.state.updateColumnWidth(this.colId, width);
}
}
// ----------------------------------------------------------------
// 3. Parent Component: The Grid
// Reacts to the total width calculated by children.
// ----------------------------------------------------------------
@Component({
selector: 'app-root',
standalone: true,
imports: [GridColumnComponent],
template: `
<div class="p-8">
<h1 class="text-xl font-bold mb-4">Zoneless Grid Layout</h1>
<!-- The Problem Area: Reading a signal that children try to update -->
<div
class="bg-blue-50 p-4 mb-4 transition-all duration-300"
[style.width.px]="state.totalWidth()"
[style.border]="state.totalWidth() > 500 ? '2px solid red' : '2px solid blue'"
>
<p>Total Calculated Width: {{ state.totalWidth() }}px</p>
</div>
<div class="flex flex-row gap-2">
<app-grid-column>Column 1 (Short)</app-grid-column>
<app-grid-column>Column 2 (Significantly Longer Content)</app-grid-column>
<app-grid-column>Col 3</app-grid-column>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
protected state = inject(GridStateService);
}
// ----------------------------------------------------------------
// 4. Bootstrap with Zoneless
// ----------------------------------------------------------------
bootstrapApplication(App, {
providers: [
provideExperimentalZonelessChangeDetection()
]
}).catch((err) => console.error(err));
Why This Works
The logic leverages the specific execution phases of the Angular rendering engine:
- Change Detection: Angular updates the bindings in
AppandGridColumnComponent. - DOM Update: The DOM nodes for the columns are created/updated with text.
- Paint: The browser draws the frame.
afterNextRenderExecution:- This hook runs after the paint.
- The DOM is stable.
getBoundingClientRect()returns accurate values (no layout thrashing within the frame). - The
updateColumnWidthcall updates the Signal.
- Re-render: Because the Signal changed, Angular schedules a new change detection pass for the
Appcomponent.
Why not queueMicrotask or Promise.resolve()?
Microtasks run immediately after the current synchronous code block but before the browser paints. If you update a Signal in a microtask that was scheduled during change detection, you may still trigger a "dirty view" error or cause a visual stutter where the user sees the layout jump (FOUC - Flash of Unstyled Content) before the paint occurs. afterNextRender is explicitly designed to safely coordinate with the rendering pipeline.
Architectural Implication
In Zoneless architectures, derived state should be pure. If Signal B depends on Signal A, use computed(() => ...).
However, when state depends on the DOM geometry (which is a side effect of rendering), you cannot use computed. You must treat the DOM as an external system.
The pattern is:
- Render (based on initial state).
- Wait (via
afterNextRender). - Measure (read DOM).
- Signal Update (trigger reactive update).
This explicitly separates the "cause" (content) from the "effect" (layout calculation) into two separate render frames, ensuring stability and preventing NG0100.