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 callupdate()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
OnPushcomponent 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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


