In Angular, why does pushing a line item into an array held by a signal leave a computed() total and the view unchanged?
answer
- same reference in, same reference out
- Object.is by default
- mutation bypasses the setter
- mutate() is gone since v17
- spread into a new array
basics
~20 sPushing edits the array without telling the signal, and even set() or update() with that same array is equal under the default Object.is check. No dependent is notified, so the computed keeps its cached total. Return a new array instead.
solid answer
~40 sA signal only learns about changes through `set()` or `update()`, and both first compare old and new values with `Object.is` unless an `equal` option says otherwise. `items().push(x)` changes the array behind the signal's back: no setter runs, so nothing is notified. `items.update(list => { list.push(x); return list; })` does run the setter, but it hands back the same reference, which `Object.is` calls equal, so again nothing is notified. The `computed()` total keeps its cached value, and the view is not marked for refresh. The fix is an immutable update: `items.update(list => [...list, x])`, or `{...item, qty}` for an edited line. `mutate()`, which once allowed in-place edits, was removed in v17.
code
ts · 31 linesimport {Injectable, computed, signal} from '@angular/core';
interface LineItem {
sku: string;
unitPrice: number;
qty: number;
}
@Injectable({providedIn: 'root'})
export class CartStore {
private readonly _items = signal<readonly LineItem[]>([]);
readonly items = this._items.asReadonly();
readonly subtotal = computed(() =>
this._items().reduce((s, i) => s + i.unitPrice * i.qty, 0),
);
// Bug: same reference, Object.is says equal, nothing is notified.
// addBroken(line: LineItem) {
// this._items.update(list => { (list as LineItem[]).push(line); return list; });
// }
add(line: LineItem) {
this._items.update(list => [...list, line]);
}
setQty(sku: string, qty: number) {
this._items.update(list =>
list.map(i => (i.sku === sku ? {...i, qty} : i)),
);
}
}go deeper
Recall that signal writes must hand over a new array or object, for example with spread, because the same reference counts as no change.
Explain the Object.is check in set() and update(), why push() never reaches the setter, and why the memoized computed keeps its stale total.
Diagnose the list-versus-total mismatch, and prevent it with private writable signals, asReadonly() views and readonly array types in the store's public API.
Decide how the team enforces immutable updates, through typing, lint rules or review, and when the copying cost of large collections would justify a different structure.
## The symptom A cart service keeps its lines in `items = signal<LineItem[]>([])` and derives `subtotal = computed(() => ...)`. A new "add to cart" button calls `this.items().push(line)`. The line appears in the list after some unrelated click, but the subtotal still shows the old amount, and in a component with nothing else going on, nothing changes at all. ## How a signal decides that it changed In Angular 22.2 every write goes through the same check: 1. `set(value)` or `update(fn)` produces a candidate value. 2. The signal calls its **equality function** with the old and new value. The default is `Object.is`, which for objects and arrays means **same reference**. 3. If they are equal, the write is dropped: the old value is kept and **no dependent is notified**. 4. Only if they differ does the signal store the new value and notify its consumers: `computed()` signals that read it, effects, and templates. There is no deep comparison and no proxy watching the array's contents. ## The three ways a mutation slips through | Code | Setter runs? | Equality result | Dependents notified? | |---|---|---|---| | `items().push(line)` | no | not checked | **no** | | `items.update(l => { l.push(line); return l; })` | yes | same reference, equal | **no** | | `items.set(items())` after mutating | yes | same reference, equal | **no** | | `items.update(l => [...l, line])` | yes | new reference, not equal | **yes** | The first row never reaches the signal. The next two reach it but look like no-ops. Only the immutable update produces a change the signal can see. ## Why the list and the total disagree This is the part that confuses people in production: - A `computed()` is **memoized**. Its cached total is only recomputed after one of its dependencies notifies. No notification means the stale total is returned forever. - A template's `@for (line of items(); track line.sku)` block, by contrast, **diffs the collection every time the view is checked**. If anything else causes that view to be checked, the loop reads the same (mutated) array, sees an extra element and renders it. So a mutated array can produce a list with four lines and a total for three. Because components are `OnPush` by default since v22 and zoneless is the default since v21, "anything else" happens less often than in older apps, which is why the bug often shows up as "nothing updates" in new code and as "the total lags behind" in older code. ## Fixes - **Immutable updates** are the rule: - add: `items.update(l => [...l, line])` - remove: `items.update(l => l.filter(x => x.sku !== sku))` - edit one line: `items.update(l => l.map(x => x.sku === sku ? {...x, qty} : x))` - **Keep the writable signal private** and expose `asReadonly()`, so components cannot call `items().push()` on a reference they got from the service. Remember that `asReadonly()` does not freeze the value; it only removes `set()` and `update()`. Typing the value as `readonly LineItem[]` adds a compile-time guard against `push()`. - **Do not reach for `equal: () => false`.** It forces every write to notify, including genuine no-ops, and it defeats the memoization downstream `computed()` signals rely on. It hides the mutation instead of removing it. ## History worth knowing Early signal previews offered `WritableSignal.mutate(fn)`, which edited the value in place and forced a notification. The v17 release removed it from the public API, and its migration note tells you to use `update()` and make immutable changes. Code or answers that still mention `mutate()` as current are from the preview era. ## What this is not This is not a signal "failing to track arrays". Signals track **values** handed to them, and an array's identity is its value under the default equality. The general reasoning about reference versus deep equality belongs to framework-neutral state theory; the Angular-specific facts are the `Object.is` default, the early return in the setter, the memoized `computed()` and the removed `mutate()`.
- In Angular, would a custom deep-equality equal option make the in-place push work?No. After an in-place push the old and new value are the same array, so any sensible equality function, deep or shallow, reports them equal and the write is dropped. Only a function that reports identical arguments as different, such as one that always returns false, would notify, and that also defeats every legitimate no-op check.
- Why can the @for list show the new line while the computed() total does not?The `@for` block diffs the collection each time its view is checked, so an unrelated check picks up the mutated contents. The `computed()` only recomputes after a dependency notifies, which never happened, so it keeps returning its cached total.
saying these in an interview costs you the question
- Signals deep-watch arrays and objects, so push() is picked up automatically.
- Returning the mutated array from update() is enough to notify dependents.
- A deep-equality equal option fixes in-place mutation of the same array.
- asReadonly() prevents consumers from mutating the array they read.
- Use mutate() for arrays and update() for primitives.