An Angular OnPush order-table component shows no new row after the parent calls this.orders.push(newOrder); why, and how would you fix it?
answer
- same array, same reference
- identity check on the input binding
- why a click inside helps
- replace instead of mutate
basics
~20 spush() mutates the array in place, so the [orders] binding sees the same reference, the OnPush table is never marked dirty, and its @for never re-runs. Replace the array instead, ideally held in a signal and updated with a new copy.
solid answer
~50 sWhen the parent refreshes, the `[orders]` binding compares the new value with the old one using `Object.is`. After `push()` it is the same array object, so the input is not written, the `OnPush` table is not marked dirty, and the pass skips it; the `@for` block never sees the new item. It looks intermittent because a click inside the table marks it dirty, the template runs, and the mutated array suddenly renders. Before v22 the table would have been always-check, which hid the bug. Fix the data flow: keep the orders in a signal and write `orders.update(list => [...list, newOrder])`. A signal updater that pushes and returns the same array fails too, because signals also compare with `Object.is`. Calling `markForCheck()` in the parent does not help, since it marks the parent and its ancestors rather than the table, and switching the table to `Eager` only hides the mutation.
code
ts · 39 linesimport {Component, input} from '@angular/core';
export interface Order {
id: number;
item: string;
qty: number;
}
@Component({
selector: 'app-order-table',
template: `
<table>
@for (order of orders(); track order.id) {
<tr><td>{{ order.id }}</td><td>{{ order.item }}</td><td>{{ order.qty }}</td></tr>
}
</table>
`,
})
export class OrderTable {
readonly orders = input.required<Order[]>();
}
@Component({
selector: 'app-orders-page',
imports: [OrderTable],
template: `
<button (click)="addOrder()">Add</button>
<app-order-table [orders]="orders" />
`,
})
export class OrdersPage {
orders: Order[] = [];
private nextId = 1;
addOrder() {
// Bug: same array reference, so the OnPush table is never marked dirty.
this.orders.push({id: this.nextId++, item: 'Desk lamp', qty: 1});
}
}go deeper
Recall that OnPush compares input references, so push() on a bound array is invisible to the child.
Walk through the pass step by step: parent refreshed, binding compared with Object.is, child not marked, @for never runs.
Explain why the bug looks intermittent and why it surfaced after the v22 default change, then rank immutable updates above markForCheck() or Eager.
Decide how the team prevents the class of bug: immutable-update conventions, signals for component state, and review rules against in-place mutation of bound data.
## The symptom An orders page renders an `OrderTable` component, which lists orders with a `@for` block. The page's "Add" button calls `this.orders.push(newOrder)` and passes `orders` to the table through `[orders]="orders"`. Since Angular 22 both components are `OnPush` unless they say otherwise. The new order is in the array, but no row appears. Then, apparently at random, it shows up. ## Why nothing happens Follow the pass that runs after the click: 1. The `(click)` listener marks the page's view and its ancestors dirty, then runs `addOrder()`, which appends to the **existing** array. 2. The next pass refreshes the page's view and evaluates the `[orders]` binding. 3. The binding compares the new value with the previous one using `Object.is`. It is the **same array object**, so the comparison reports no change and the input is not written. 4. Because the input did not change, the `OnPush` table's view is **not marked dirty**, and the pass skips it. 5. The table's template never runs, so its `@for` block never reconciles the array and never creates the row. The data is correct; the view was simply never asked to look at it. `OnPush` compares identity, not contents, and `push()` changes contents only. ## Why it looks intermittent - **A click inside the table** (a sort header, a row button) is an Angular-bound event in the table's view. It marks the table dirty; on that pass the template runs, `@for` reconciles the mutated array, and the missing row appears. - **A new array from elsewhere**, such as a reload that assigns a fresh result, passes the identity check, so everything catches up at once. - **It worked before v22.** Components without a strategy used to be always-check (then `Default`, now `Eager`), so the table was refreshed whenever the page was, and `@for` saw the mutated array on every pass. Code written against that behaviour breaks once the component is `OnPush`. ## Fixes, ranked | Fix | Row appears? | Notes | |---|---|---| | Hold orders in a `signal` and `update(list => [...list, o])` | yes | new identity for the binding and a signal notification; the current recommendation | | Assign a new array: `this.orders = [...this.orders, o]` | yes | same identity rule, without signals | | `update(list => { list.push(o); return list; })` | **no** | a writable signal compares with `Object.is` by default, so returning the same array is "no change" | | `markForCheck()` in the parent after `push()` | **no** | it marks the parent and its ancestors, not the table, and the binding still sees the same array | | Set the table to `ChangeDetectionStrategy.Eager` | yes | gives up skipping the table's subtree to hide a data-flow bug | The durable fix is **immutable updates**: every change produces a new array or object, so every identity check in the chain (input bindings, signals, `computed()`, pure pipes) sees it. ## Why Angular compares identity - **It is cheap.** An `Object.is` check costs the same for a three-item array and a three-thousand-item one; a deep comparison on every pass could easily cost more than the refresh it is meant to avoid. - **It is predictable.** The rule "a new reference means a change" is the same for input bindings, writable signals, `computed()` values and pure pipes, so one habit, immutable updates, satisfies all of them. - **It puts the knowledge where it lives.** The code that changes the data knows it changed it; producing a new object is how it tells the rest of the tree. ## The row-level variant The same bug appears one level down. If each row is its own `OnPush` component bound with `[order]="order"` and orders carry a `status` field, then `order.status = 'shipped'` leaves that row stale for the same reason. Replace the object instead, for example `orders.update(list => list.map(x => x.id === id ? {...x, status: 'shipped'} : x))`. The `track order.id` expression in `@for` still lets Angular keep the other rows' DOM, so the cost of a new array stays small. ## What to check in review - No `push`, `splice`, `sort` or property assignment on state that is bound to an `OnPush` input. - Signal updaters return new values rather than mutating and returning the old one. - A `markForCheck()` added "to make the row show up" is a prompt to look for a mutation upstream, not a fix to approve.
- A teammate writes orders.update(list => { list.push(o); return list; }) with a signal. Why is the row still missing?A writable signal compares old and new values with `Object.is` by default. Returning the same, mutated array counts as no change, so the signal does not notify and the input binding still receives the same reference. Return a new array, `[...list, o]`, so both the signal and the binding see a new identity.
- Why does the missing row appear as soon as a user clicks a sort header inside the table?The header's `(click)` is an Angular-bound event in the table's view, so it marks the table and its ancestors dirty. On that pass the table's template runs and `@for` reconciles the array it already holds, which contains the pushed order. The data was there all along; the view had not been refreshed.
- Is setting the table to ChangeDetectionStrategy.Eager an acceptable fix?It makes the row appear, because an Eager table is refreshed whenever its parent is and `@for` reconciles the mutated array. But it gives up skipping the table's subtree, and the mutation stays invisible to every other identity-based consumer, such as a `computed()` or a pure pipe over the array. Fix the update instead.
An OnPush view is a notice board you only re-read when someone pins a new sheet. Writing an extra line on the sheet that is already pinned changes the information, but nobody walks over to read it until a new sheet goes up.
saying these in an interview costs you the question
- OnPush notices that the array's length changed, so push() should be detected.
- Calling markForCheck() after every push is the proper fix.
- Moving the array into a signal fixes it even if update() returns the same array.
- The track expression in @for is why the new row is missing.
- Switching the table back to Eager is the recommended fix for this bug.