skip to content

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?

level: seniorimportance: must knowfreq 66%

answer

  1. same array, same reference
  2. identity check on the input binding
  3. why a click inside helps
  4. replace instead of mutate

basics

~20 s

push() 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 s

When 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 lines
ts
import {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

for a junior

Recall that OnPush compares input references, so push() on a bound array is invisible to the child.

for a middle

Walk through the pass step by step: parent refreshed, binding compared with Object.is, child not marked, @for never runs.

for a senior

Explain why the bug looks intermittent and why it surfaced after the v22 default change, then rank immutable updates above markForCheck() or Eager.

for a principal

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.