ChangeDetectionStrategy.OnPush is a promise you make to Angular: “this component’s output depends only on its inputs and on things that will tell you when they change”. In exchange, Angular skips the component’s template during change detection unless it has a reason to check it. The most common reason, and the one that causes the most confusion, is an input getting a new value.
“New value” means a new reference. That single detail is behind the classic interview question: “I changed user.role in the parent, why does the OnPush child still show the old role?” This note answers it with a real run, then covers the other ways an OnPush view gets refreshed and what signals change.
All output below comes from Angular 20.3 running under Node with TestBed. Angular 19 behaves the same. (If you’re on Angular 22 or later, note that components without an explicit changeDetection setting became OnPush by default there, so this matters even more.)
The setup
A parent page passes a user object to an OnPush card:
@Component({
selector: 'user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ user.name }} ({{ user.role }})`,
})
export class UserCard {
@Input() user!: { name: string; role: string };
}
@Component({
selector: 'app-page',
imports: [UserCard],
template: `<user-card [user]="user" />`,
})
export class Page {
user = { name: 'Asha', role: 'viewer' };
}The parent uses the default (eager) strategy, so it’s checked every time. The question is what happens to the child. With a counting getter added to the child’s template, here are four changes in the parent, with change detection run after each:
action | rendered | child template ran?
nothing changed | Asha (viewer) | no
mutate user.role | Asha (viewer) | no
replace with spread | Asha (admin) | yes (ngOnChanges fired)
assign same reference | Asha (admin) | noWhy mutation is skipped
When Angular checks the parent’s template, it evaluates the binding [user]="user" and compares the result to the value it stored last time, using Object.is. After this.user.role = 'admin', this.user is still the same object. Same reference, so as far as the binding is concerned nothing changed. The child isn’t marked dirty, ngOnChanges doesn’t fire, and Angular skips the child’s template entirely.
The data did change. If you logged user.role inside the child you’d see 'admin'. The view is stale because nobody asked it to re-render, not because the child has old data.
That distinction is worth saying out loud in an interview. “OnPush doesn’t see the change” is vague. “The input binding compares by reference, the reference didn’t change, so the view wasn’t marked for check” is the answer they’re listening for.
Why a new object works
this.user = { ...this.user, role: 'admin' };Now the binding produces a different reference. Angular sets the child’s input, fires ngOnChanges with a SimpleChanges entry for user, marks the child dirty, and checks its template. The table shows “Asha (admin)” and the child’s template ran.
The last row is the mirror image: this.user = this.user is an assignment, but it’s the same reference, so nothing happens. The assignment itself has no special meaning to Angular. Only the comparison does.
Spreading is shallow, and that’s usually what you want. If the user has an address object and you change only the role, the new user still shares the old address. A nested OnPush component bound to [address]="user.address" sees the same reference and skips, which is correct, since its data didn’t change. Copy along the path to the thing you changed and reuse everything else. The note on immutable collection updates works through the list version of this.
The other ways an OnPush view gets checked
Inputs aren’t the only trigger. An OnPush view is marked for check when:
- An input binding gets a new reference, from a template or
componentRef.setInput(). - A template or host event fires in the component or one of its children. Clicking a button in the card refreshes the card.
- The
asyncpipe receives a value. It callsmarkForCheck()internally, which is one reason it pairs so well with OnPush. - A signal read in the template changes. The view is notified directly.
- You call
ChangeDetectorRef.markForCheck().
What’s missing from that list is “some code ran”. A setTimeout, a WebSocket message or a subscribe() callback that assigns a plain field does not mark an OnPush view dirty. In the test run, setting card.label directly on the child left the view unchanged, and calling markForCheck() afterwards made the new label appear. With Zone.js, the timer still triggers a change detection pass, but the pass walks past the clean OnPush child. Without Zone.js (zoneless), nothing triggers a pass at all.
Interview tip
markForCheck()marks the view and its ancestors up to the root, so the next pass reaches it.detectChanges()runs a check on that view immediately. PrefermarkForCheck(); reaching fordetectChanges()to make a stale view appear usually hides a design problem.
Where signals fit
Signal inputs (user = input.required<User>()) don’t change the identity rule. The input signal’s value is set when the parent binding produces a new reference, the same comparison as before. Mutating user.role in the parent still doesn’t change the signal’s value, so a computed(() => this.user().role) won’t update either.
What signals add is precision. If the child keeps internal state in a signal and reads it in the template, a change to that signal marks the view for check without any markForCheck() call. That’s why signal-based components “just work” with OnPush where field-based ones needed the async pipe or manual marking. The change detection deep dive covers the scheduling side.
When mutation seems to work anyway
You’ll sometimes see a mutated input render correctly, and that makes the bug intermittent. Two common reasons:
- Something else marked the view. If the user clicks a button inside the card after the mutation, the event marks the card dirty, the template re-reads
user.role, and the new value appears. It looks like a delayed update. - The child isn’t actually OnPush. An eager child is checked on every pass, so it reads the mutated field and renders it. The bug appears the day someone adds OnPush for performance, and the commit gets blamed instead of the mutation.
Either way, the fix is in the parent: produce a new object when the data changes.
Interview practice
An OnPush OrderTable receives [orders]="orders". A WebSocket handler in the parent does this.orders.find(o => o.id === msg.id)!.status = msg.status;. Statuses don’t update on screen until the user sorts the table, then all of them jump to the right value. Explain the behaviour and give two fixes, saying which you’d prefer.
Answer
The handler mutates an order object inside the existing array. The orders reference never changes, so the input binding comparison says “same”, the table isn’t marked dirty, and its template is skipped. The data is correct in memory, which is why everything snaps into place when the user sorts: clicking the sort header is a template event inside OrderTable, that marks it dirty, and the next check reads the already-mutated statuses.
Fix one, which I’d prefer, is to update immutably in the parent: this.orders = this.orders.map(o => o.id === msg.id ? { ...o, status: msg.status } : o). The array is new, so the table refreshes, and only the changed order is a new object, so an OnPush row component with track order.id refreshes just that row. Even better, keep orders in a signal and do the same thing with orders.update(...); then the view is notified even without Zone.js.
Fix two is to inject ChangeDetectorRef in the parent and call markForCheck() after the mutation. That would make the parent and its ancestors dirty, but it would not mark the OnPush table, because the table’s input reference still hasn’t changed. You’d have to call markForCheck() from inside the table, which means the table needs to know about the socket. That’s why it’s the weaker fix: it treats the symptom, depends on where the call is made, and leaves the next OnPush child to rediscover the same bug.