Signals are a notification system. When you call set() or update(), Angular compares the new value with the old one, and if they’re different it tells every computed signal, effect and template that read it. The comparison is the part that catches people with arrays and objects: by default it’s Object.is, which for objects means “is this the same reference?”, not “does it contain the same data?”.
That one rule explains a whole family of “my list doesn’t update” bugs. This note works through them with a task list, using a component that was rendered with Angular 20.3’s TestBed under Node, so every screen value below is real output. The same rules apply in Angular 19.
The task list that stops updating
@Component({
selector: 'app-tasks',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ tasks().length }} items, {{ remaining() }} left`,
})
export class Tasks {
tasks = signal<Task[]>([{ id: '1', title: 'Write tests', done: false }]);
remaining = computed(() => this.tasks().filter((t) => !t.done).length);
}Now try a few ways of adding and completing items, and compare what the template shows with what’s actually in the array:
action | screen | real array length
initial | 1 items, 1 left | 1
push() in place | 1 items, 1 left | 2
push() then set(same ref) | 1 items, 1 left | 3
update with spread | 4 items, 4 left | 4
toggle by mutation | 4 items, 4 left | 4 (data really has 3 left)
toggle #4 with map + spread | 4 items, 2 left | 4Read that table row by row, because each row is a different misconception.
Why push() is invisible
this.tasks().push({ id: '2', title: 'Ship', done: false });this.tasks() returns the array the signal holds. push() changes that array, but the signal itself was never told. No set, no update, no notification. The template still says 1 item while the array has 2.
A plain signal() has no idea what you do with the value after you read it. Unlike the Zone.js era, where any change in any callback was picked up by a full tree check, signal-driven views (and OnPush views in general) refresh only when something tells them to.
Why set() with the same array doesn’t help
The second row is the one people argue about:
const list = this.tasks();
list.push({ id: '3', title: 'Deploy', done: false });
this.tasks.set(list);This does call set(). It still doesn’t update the screen, because Object.is(list, list) is true. The signal compares old and new, sees the same reference, and concludes nothing changed. The same happens inside update() if your callback mutates the array and returns it:
this.tasks.update((l) => { l.push(item); return l; }); // same reference, no notificationA quick test outside any component confirms it: a computed that reads the signal ran once, then after s.set(sameArray) it still had run once.
Producing a new value
The fix is to return a new array, so the reference changes:
add(title: string) {
this.tasks.update((list) => [...list, { id: crypto.randomUUID(), title, done: false }]);
}
remove(id: string) {
this.tasks.update((list) => list.filter((t) => t.id !== id));
}filter and map already return new arrays, so they’re naturally safe. Spread is the usual tool for adding. The fourth row of the table shows the result: the screen jumped to 4 items, because the new array finally notified everyone, and the template also caught up on the two items that were pushed earlier.
Editing one item inside the list
Toggling a single task is where the mistakes get subtle:
// Looks immutable, isn't:
this.tasks.update((list) => {
const t = list.find((x) => x.id === id)!;
t.done = !t.done;
return list;
});It uses update(), so it reads as correct. But it mutates an object inside the array and returns the same array. In the table, after this “toggle by mutation”, the screen still said 4 left while the data had 3.
Then look at the last row. Toggling a different item the right way made the screen jump straight to 2 left, picking up both changes at once. This is why mutation bugs are so annoying in practice: they appear to fix themselves when some unrelated action produces a new reference, and testers report them as “the count is sometimes one behind”. If you hear that symptom in an interview scenario, say “mutation” first.
The correct version replaces the array and the one object that changed:
toggle(id: string) {
this.tasks.update((list) =>
list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
);
}Notice that the other objects are reused, not copied. That’s deliberate. An @for (task of tasks(); track task.id) loop and any OnPush child receiving [task]="task" see unchanged references for unchanged items and skip them. Only the toggled row is a new object, so only its child refreshes. Deep-cloning the whole list with structuredClone would also “work”, but it makes every row look new and throws away that efficiency.
Nested objects and how deep to copy
The rule generalises: copy every object on the path from the signal’s root to the thing that changed, and nothing else. For a user profile with an address:
profile = signal({ name: 'Asha', address: { city: 'Pune', zip: '411001' }, tags: ['admin'] });
moveTo(city: string) {
this.profile.update((p) => ({ ...p, address: { ...p.address, city } }));
}Spreading only p would give a new profile object that still shares the old address. That’s fine for anything reading profile(), but a computed that reads profile().address and uses a custom equal check, or an OnPush child bound to [address], would see the same address reference and skip the update. Copy down the path.
If these spreads get deep and noisy, that’s usually a sign the state shape is wrong. Splitting address into its own signal, or normalising a list into a map keyed by id, often removes the nesting entirely. Libraries like Immer let you write mutation-style code that produces new objects, and they work fine with signals, but they’re a convenience, not a requirement.
Gotcha
Mutating a signal’s value is never an error. Angular doesn’t freeze it, and nothing throws. Treat values read from a signal as read-only by habit, or type them as
ReadonlyArray<Task>/readonlyfields so the compiler stopspush()for you.
Typing the rule into the code
The cheapest prevention is in the types:
type Task = Readonly<{ id: string; title: string; done: boolean }>;
tasks = signal<readonly Task[]>([]);With that, this.tasks().push(x) and t.done = true are compile errors (TS2339 Property 'push' does not exist on type 'readonly Readonly<...>[]', and TS2540 Cannot assign to 'done' because it is a read-only property). The spread, map and filter versions still compile. It costs nothing at runtime and turns a silent bug into a red squiggle.
Interview practice
A colleague’s shopping cart uses items = signal<CartLine[]>([]) and a quantity stepper calls this method. Users report that the line total updates but the cart badge in the header (an OnPush component reading a computed of the item count) is sometimes stale. What’s happening, and how would you fix it?
setQty(id: number, qty: number) {
const line = this.items().find((l) => l.id === id)!;
line.qty = qty;
this.items.set([...this.items()]);
}Answer
This one is a trap because it does create a new array, so the signal notifies. The issue is that the line object inside it is the same reference, mutated in place. The line total updates because the row template happens to be re-rendered when the array changes, and it reads line.qty directly. But anything downstream that compares the line by reference, such as an OnPush CartLineComponent with line as an input, or a computed with a custom equal, sees the same object and may skip work. And a computed that only depends on items().length won’t change at all, because the length didn’t change; if the badge shows total quantity, it has to read the quantities, not the length.
So I’d fix two things. First, replace the mutated object: this.items.update(list => list.map(l => l.id === id ? { ...l, qty } : l)), which gives the changed line a new reference and keeps the others. Second, make sure the badge derives from what it displays: count = computed(() => this.items().reduce((n, l) => n + l.qty, 0)). Then I’d add readonly to the CartLine type so the in-place assignment can’t come back, and mention that the “sometimes stale” symptom is characteristic of mutation, because the next unrelated reference change paints over it.