skip to content

In a zoneless Angular app, a third-party map widget's move callback assigns this.center, but the displayed coordinates never update; why, and how do you fix it?

level: seniorimportance: should knowfreq 46%

answer

  1. the callback is not Angular's
  2. no zone patched its listener
  3. NgZone is a no-op now
  4. signal set inside the callback

basics

~20 s

The widget calls back through its own event API, not an Angular-bound listener, and no zone.js observes it, so assigning a plain field notifies nothing. Store the value in a signal the template reads and set() it in the callback.

solid answer

~50 s

Under zone.js this worked by accident: the widget registered DOM listeners and timers while running inside the Angular zone, so zone.js saw its callbacks finish and triggered a check. In a zoneless app the only triggers are Angular notifications. The widget's `moveend` callback is not a bound template or host listener, and `this.center = c` calls no Angular API, so no check is scheduled; the OnPush view (the v22 default) is not even refreshed by unrelated checks. The fix is to make `center` a signal read by the template and call `this.center.set(c)` in the callback, which marks the view and schedules a check. `ChangeDetectorRef.markForCheck()` after the assignment also works where signals are not an option. Wrapping the callback in `NgZone.run()`, the usual zone-era fix, does nothing here: the injected `NgZone` is a no-op whose `run()` just calls the function. Create the widget in `afterNextRender` and destroy it through `DestroyRef`.

code

ts · 28 lines
ts
import {Component, DestroyRef, ElementRef, afterNextRender, inject, signal, viewChild} from '@angular/core';

interface MapWidget {
  on(event: 'moveend', cb: (c: {lat: number; lng: number}) => void): void;
  destroy(): void;
}
declare function createMapWidget(host: HTMLElement): MapWidget;

@Component({
  selector: 'app-store-map',
  template: `
    <div #host class="map"></div>
    <p>Centre: {{ center().lat }}, {{ center().lng }}</p>
  `,
})
export class StoreMap {
  private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
  readonly center = signal({lat: 0, lng: 0});

  constructor() {
    const destroyRef = inject(DestroyRef);
    afterNextRender(() => {
      const map = createMapWidget(this.host().nativeElement);
      map.on('moveend', (c) => this.center.set(c));
      destroyRef.onDestroy(() => map.destroy());
    });
  }
}

go deeper

for a junior

Recognise that a value changed by a non-Angular library callback does not show in a zoneless app unless it is stored in a signal the template reads.

for a middle

Explain which notifications exist, why a library's own event API is not one of them, and why NgZone.run() no longer helps.

for a senior

Diagnose the missing notification from the symptom, fix it with a signal write, and wrap the widget with afterNextRender and DestroyRef so it is browser-only and leak-free.

for a principal

Turn the one fix into a policy: a thin wrapper convention for all non-Angular libraries that converts their events into signals at the boundary.

## The scenario A store-locator page embeds a **third-party map widget**, a plain JavaScript library that is not an Angular component. The Angular wrapper creates the map on a host `<div>`, subscribes to the widget's own `moveend` event and copies the new centre into a field that the template prints. In a **zoneless** Angular app (the default since v21) the map pans, but the coordinates under it never change. ## Why it worked with zone.js With zone.js loaded, everything the widget did inside the Angular zone was observed. Its internal `addEventListener` and `setTimeout` calls were patched, so when its `moveend` callback ran and finished, `NgZone` noticed that a task had completed and Angular ran a check of the whole tree. The field assignment was picked up by that check. Nobody in the codebase ever had to think about it. Performance-minded teams often went further and created the widget inside `NgZone.runOutsideAngular()` so that its high-frequency pointer and animation events would not trigger a check each, then re-entered with `NgZone.run()` only for the events that changed displayed state. That pattern is correct for zone-based apps, and it is the reason many wrappers now contain `run()` calls that look like they should still work. ## Why it fails zoneless A zoneless app schedules change detection only after an Angular **notification**: - a signal read by a template is set; - `ChangeDetectorRef.markForCheck()` is called; - a **bound** template or host listener runs; - `ComponentRef.setInput()` is called, or a dirty view is attached. The widget's callback is none of these. It is registered through the library's own API, not through an Angular template binding, so Angular does not wrap it. The assignment `this.center = c` is a plain property write. Result: no notification, no check. Because components are `OnPush` by default since v22, even a later unrelated check (a click on a filter button) does not refresh this view, since it was never marked dirty. ## The broken code, and the zone-era non-fix ```ts map.on('moveend', (c) => { this.center = c; // plain field: no notification }); map.on('moveend', (c) => { this.zone.run(() => (this.center = c)); // NoopNgZone: still nothing }); ``` In zone-based apps with `runOutsideAngular()` around third-party setup, `NgZone.run()` was the standard way back in. In a zoneless app the injected `NgZone` is a **no-op implementation**: `run()` simply calls the function. Its presence is harmless, but it schedules nothing. ## The fix | Option | When to use it | |---|---| | Store the value in a **signal** and `set()` it | The default choice; the template read notifies Angular | | Call `ChangeDetectorRef.markForCheck()` after assigning | Legacy components you cannot move to signals yet | | Route the events through an observable and `AsyncPipe` | When the rest of the component is stream-based | The signal version is shown in the code example below. Two related habits make the wrapper robust: 1. **Create the widget in `afterNextRender`**, so it runs in the browser after the host element exists, and never on the server. 2. **Tear it down with `DestroyRef.onDestroy()`**, so the widget's listeners do not outlive the component. If the widget fires dozens of events per second, a signal write per event is cheap: writes are coalesced, so every event that arrives before the scheduled check runs is absorbed by that one check. What would be expensive is a zone-based app where every one of those events triggered a full tree check, one of the problems zoneless removes. ## How to find the rest The same pattern hides wherever a non-Angular callback mutates template state: WebSocket clients, `IntersectionObserver` and `ResizeObserver` callbacks, charting libraries, `postMessage` handlers and raw `addEventListener` calls in services. Search for field assignments inside those callbacks. In development, `provideCheckNoChangesConfig({exhaustive: true, interval})` periodically re-checks every view and throws `ExpressionChangedAfterItHasBeenCheckedError` when a binding changed without a notification, which surfaces the stragglers.

  • Should you delete existing NgZone.runOutsideAngular() and run() calls when migrating an Angular library to zoneless?
    No. The zoneless guide says both are compatible with zoneless apps and removing them can cause performance regressions for applications that still use zone.js. In a zoneless app they are simply no-ops. What must change is state reaching the template: it needs a signal write, markForCheck() or AsyncPipe, not a zone re-entry.
  • A map widget fires dozens of move events per second; is a signal write per event too expensive in a zoneless Angular app?
    Usually not. The first write schedules a check and later writes before it runs are coalesced, so a burst of events that arrives before the check runs costs one check, not one per event. If even that is too much, subscribe to the widget's end-of-gesture event instead of its continuous one, or throttle the callback before it writes the signal.

saying these in an interview costs you the question

  • Wrapping the callback in NgZone.run() makes the view update.
  • Any callback that runs in the browser triggers change detection.
  • The widget must be rewritten as an Angular component to work.
  • A signal write per map event causes one full check per event.
  • It fails because third-party widgets are unsupported without zone.js.