Why Isn’t My Angular Component Updating? Zoneless and OnPush Debugging [2026]

Link copied
Why Isn’t My Angular Component Updating? Zoneless and OnPush Debugging [2026]

Why Isn’t My Angular Component Updating? Zoneless and OnPush Debugging [2026]

The API call succeeded, the console log shows the new data, and the screen still shows the old value. Click anywhere and it suddenly appears. In a zoneless, OnPush Angular app this is the most common bug report, and it almost always has the same shape: something changed, but nothing told Angular about it. Zone.js used to paper over that by checking everything after every async task. Without it, every update needs a reason.

This is lesson 10.3 of the Angular Tutorial. Lesson 10.2 dealt with values that change too late in a pass; this lesson deals with values that change and never reach the screen. You'll learn the short list of things that refresh a view, a step-by-step diagnosis for a component that isn't updating, and how to use Angular DevTools to confirm each step.

What refreshes a view #

New Angular apps have been zoneless by default since v21, and in v22 new components are OnPush by default. In that setup Angular refreshes a component's view only when it has been notified:

Notification Example
A signal read in the template changes count.set(5) when the template shows {{ count() }}
A template or host event listener runs (click)="save()", host: { '(keydown)': ... }
An input receives a new value Parent binds a new reference to [order]
The async pipe receives a value An Observable bound with the async pipe emits (the pipe calls markForCheck())
ChangeDetectorRef.markForCheck() or ComponentRef.setInput() Imperative notification

And these do not refresh anything on their own:

Not a notification Why it used to work
Assigning a plain field in setTimeout, fetch().then, a WebSocket callback Zone.js patched the async API and ran a global check afterwards
Mutating an object or array that's already in a signal or input Under the Default strategy, every binding was re-evaluated anyway
A third-party library callback that changes component state Zone.js caught the library's timers and events

So the question for every stale component is: which notification was supposed to happen, and why didn't it? The rest of this lesson is a checklist that answers it.

Step 1: Did the value actually change? #

Signals and OnPush inputs compare by reference with Object.is. If you mutate the existing object, the reference is the same and nothing is notified:

items = signal<Item[]>([]);

addItem(item: Item) {
  this.items().push(item);                     // ✗ mutates; signal not notified
  this.items.update(list => { list.push(item); return list; });   // ✗ same array returned
  this.items.update(list => [...list, item]);  // ✓ new array
}

rename(id: string, name: string) {
  this.items.update(list =>
    list.map(i => (i.id === id ? { ...i, name } : i)),   // ✓ new object for the changed item
  );
}

The same applies to inputs: if a parent does this.order.status = 'shipped' on an object it passed to [order], the child receives the same reference and an OnPush child is not refreshed.

The other side of this: if you gave a signal a custom equal function, check that it doesn't report "equal" for values you consider different.

How to confirm: log the reference, not the contents. console.log(prev === next) before and after the update tells you immediately whether you mutated.

Step 2: Is the template reading the signal? #

A signal only notifies the views that read it while rendering. Three common ways to break that:

// ✗ snapshot taken once; the template binds a plain string
title = this.store.title();

// ✗ read hidden in a plain field updated by hand
ngOnInit() { this.count = this.counter(); }

// ✓ read the signal (or a computed) in the template
title = this.store.title;            // template: {{ title() }}
label = computed(() => `${this.store.title()} (${this.counter()})`);

Watch for templates that call a method returning a cached plain value. If getTotal() returns this.total and this.total is a field set somewhere else, the template has no reactive dependency at all.

Also check that the signal is read on the rendered path. A signal read only inside a branch that isn't currently rendered (an @if that's false) doesn't create a dependency until that branch renders.

Step 3: Is the read hidden by untracked()? #

untracked() deliberately stops a read from becoming a dependency. That's useful in effects, but if it creeps into a computed() the template depends on, updates to that signal stop propagating:

// ✗ updates to rate() never recompute total
total = computed(() => this.amount() * untracked(this.rate));

// ✓ both are dependencies
total = computed(() => this.amount() * this.rate());

The same applies to a helper function that internally wraps reads in untracked(); search for it when a computed() only recalculates for some of its inputs.

Step 4: Was a plain field changed outside an event? #

This is the zone.js habit. These all worked before zoneless and silently fail now in an OnPush component:

status = 'idle';

ngOnInit() {
  setTimeout(() => (this.status = 'ready'), 1000);            // ✗ no notification
  fetch('/api/health').then(() => (this.status = 'ok'));     // ✗ no notification
  this.socket.onmessage = e => (this.lastMessage = e.data);  // ✗ no notification
}

Fix: make the state a signal. A signal write is the notification:

status = signal<'idle' | 'ready' | 'ok'>('idle');

ngOnInit() {
  setTimeout(() => this.status.set('ready'), 1000);
}

For data loading, prefer httpResource() or resource(), which expose their value, status and error as signals. For RxJS sources, convert with toSignal() or use the async pipe. If you can't change the state type yet (a large legacy component), inject ChangeDetectorRef and call markForCheck() after the write; it's an honest stopgap, not a destination.

Step 5: Is the update happening in the component you're looking at? #

OnPush refreshes the component that was notified, plus its ancestors so Angular can reach it. It doesn't refresh unrelated siblings. Two cases trip people up:

  • State in a service. If a service holds a plain array and component A pushes into it, component B (which displays it) gets no notification. Make the service expose signals: readonly items = signal<Item[]>([]) with methods that call update() immutably.
  • A shared object passed down by reference. A template listener in a child marks that child and its ancestors, so the parent is refreshed. But if the child mutated a plain object that a sibling OnPush component also displays, the sibling isn't an ancestor, isn't notified, and receives the same reference. Keep shared state in signals rather than mutable objects.

Step 6: Is a library writing outside Angular? #

Chart libraries, maps, drag-and-drop helpers and payment widgets call your callbacks from their own event loops. In a zoneless app, a plain field write inside those callbacks doesn't notify anything:

constructor() {
  afterNextRender(() => {
    this.map = createMap(this.host.nativeElement);
    this.map.on('moveend', () => this.center.set(this.map.getCenter()));   // ✓ signal write
  });
}

Writing to a signal from the callback is all you need. There's no NgZone.run() step in a zoneless app.

Using Angular DevTools #

Angular DevTools (the browser extension) confirms each step without adding logs:

Question Where to look
What does the component hold right now? Components tab: select the component and read its properties, including current signal values
Did the value change but the view didn't? Compare the property in the Components tab with what's rendered on the page
Was the component checked at all? Profiler tab: record, trigger the update, and see whether the component appears in that change detection cycle

If the Components tab shows the new value but the Profiler shows the component wasn't checked, you're missing a notification (steps 2, 4, 5, 6). If the value itself is old, the bug is upstream: a mutation (step 1) or the data never arrived.

For a broader safety net during a migration, add provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) in development. It periodically checks every view and throws NG0100 for bindings that changed without a notification, which points straight at the field writes from step 4.

The diagnosis at a glance #

Check Quick test Typical fix
1. Did the reference change? prev === next is true Immutable update: spread, map, filter
2. Does the template read the signal? Template binds a plain field or method Bind signal() or computed() directly
3. Is the read untracked? untracked() inside the computed() chain Remove it where the value should be reactive
4. Plain field written in async code? Write inside setTimeout, then, a callback Signal, toSignal(), httpResource(), or markForCheck()
5. Right component notified? State lives in a plain service field Signals in the service
6. Library callback? Write inside a third-party event Write a signal from the callback

Gotchas #

Symptom Cause Fix
Value appears only after any click The click is a template event that refreshes the view and picks up a stale field Make the field a signal so the original write notifies
OnPush child ignores parent changes Parent mutates the input object Pass a new reference
List doesn't grow after push Mutated the array inside a signal update(list => [...list, item])
Works in the old module, broken in the new one Old code ran with zone.js; the new component is zoneless or OnPush Convert state to signals or the async pipe
computed() updates for one input but not another That read is wrapped in untracked() Read it normally
Data from a WebSocket never renders Plain field written in onmessage Signal write, or toSignal() over an Observable wrapper

What's next #

Lesson 10.4 looks at a different "it happened twice" problem: HTTP requests that fire more than once. You'll learn how cold Observables, multiple subscribers, effects that re-run and SSR without the transfer cache each produce duplicate requests, and how to tell them apart in the Network panel.

Try it yourself #

Pick a component that only updates "after you click something". Open Angular DevTools, select it, and trigger the update. If the Components tab shows the new value and the screen doesn't, search the component for field assignments inside setTimeout, then or callbacks and convert that field to a signal.

YouAfter upgrading, our NotificationsBell badge doesn’t update when a new notification arrives over the WebSocket. If I open the dropdown, the count is correct. What’s going on?
Claude · used onpush_zoneless_migrationNotificationsBell is OnPush, and the app is now zoneless. NotificationService pushes into a plain notifications: Notification[] array from the socket’s onmessage handler, and the bell reads notifications.length. Nothing notifies Angular: there’s no signal write, no template event, and the array reference never changes. Opening the dropdown is a click, which refreshes the bell and reveals the count. Fix it in the service: change the field to readonly notifications = signal<Notification[]>([]), append with this.notifications.update(list => [...list, n]), and expose unreadCount = computed(() => this.notifications().filter(n => !n.read).length). In the bell template, bind {{ notifications.unreadCount() }}. No markForCheck() or NgZone calls are needed after that.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *