Ch. 4 · Angular

Angular Computed Signals and Derived Values

Why a cart total belongs in computed() and not a second signal, how Angular tracks dependencies lazily, and when linkedSignal fits.

~7 min readbeginnerupdated Oct 7, 2026

Most state bugs in a component come from storing the same fact twice. You keep a list of cart items in one signal and the cart total in another, and sooner or later some code path updates the items and forgets the total. computed() exists so you never have to keep those two in step by hand. This note explains how it works underneath, what it does and doesn’t track, and how to talk about it when an interviewer asks “where would you put this value?”.

The examples use Angular 19 and 20. The plain signal code was run under Node with @angular/core 20.3, so the printed output is real.

Two signals for one fact

Here’s the version that looks harmless in a code review:

items = signal<CartItem[]>([]);
total = signal(0);

addItem(item: CartItem) {
  this.items.update((list) => [...list, item]);
  this.total.update((t) => t + item.price * item.qty);
}
TypeScript

It works until somebody adds removeItem(), or a “change quantity” stepper, or loads a saved cart from the server with items.set(saved). Each of those paths has to remember the total. Miss one and the badge says $99 while the list adds up to $124. The bug isn’t in any single method; it’s in the design, because total is a second copy of information that already lives in items.

The question to ask about every piece of component state is: can I calculate this from something I already have? If yes, it isn’t state. It’s a derived value, and in Angular that means computed().

Deriving the total with computed()

import { computed, signal } from '@angular/core';

type CartItem = { name: string; price: number; qty: number };

const items = signal<CartItem[]>([
  { name: 'Keyboard', price: 49, qty: 1 },
  { name: 'Mouse', price: 25, qty: 2 },
]);
const discountOn = signal(false);
const code = signal('SAVE10');

const subtotal = computed(() =>
  items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
const total = computed(() =>
  discountOn() ? subtotal() * (code() === 'SAVE10' ? 0.9 : 1) : subtotal(),
);
TypeScript

total has no setter. typeof total.set is undefined, so nobody can write a wrong number into it. To change the total you change the items, and the total follows. Any method you add later (remove, reorder, restore from storage) is automatically correct as far as the total is concerned.

In a template you read it like any other signal, {{ total() }}, and the view refreshes when it changes.

Lazy, memoized, and why that matters

Two properties of computed() come up in interviews, and both are easy to demonstrate. With a counter wrapped around the subtotal function, you can see exactly when it runs:

runs before read 0
total 99 runs 1
total again 99 runs 1
qty 3 124 runs 2
Text

Creating a computed signal does nothing. The function runs the first time something reads it, which is why “runs before read” is 0. That’s the lazy part: a derived value nobody displays costs nothing.

Reading it again without changing anything returns the cached value and doesn’t run the function. That’s memoization. When a dependency changes, Angular doesn’t recompute immediately; it marks the computed as possibly stale, and the next read recalculates. Changing the mouse quantity to 3 and reading again gave 124 and bumped the run count to 2.

This is why it’s fine to put a moderately expensive filter or reduce inside computed(), and why you should not do the same work in a plain template method call like {{ getTotal() }}. A method in a template runs on every change detection pass for that view. A computed runs only when its inputs actually changed.

Note

Memoization also means a computed should be a pure function of the signals it reads. Don’t make HTTP calls, log analytics or touch the DOM in there. If you need a side effect, that’s what effect() is for.

Dependencies follow the branch that ran

Angular doesn’t parse your function to find dependencies. It records which signals were read during the last execution. Look at total again: when discountOn() is false, the ternary never reaches code(), so code is not a dependency on that run.

That is easy to confirm. With the discount off, changing code to 'NOPE' and reading total again gives 99, and the subtotal didn’t rerun. Switch discountOn to true and the function takes the other branch, reads code(), and code becomes a dependency from that run onward.

This dynamic tracking is efficient, and it surprises people in one specific way. If you write an early return before reading a signal, that signal won’t trigger updates while the early return path is taken. That’s usually what you want. It becomes a bug only when you read something outside the signal graph, such as a plain class field or localStorage, and expect the computed to notice when it changes. It won’t. Only signal reads are tracked.

The mutation trap

The other surprise is identity. Signals compare the old and new value with Object.is by default. If you mutate an object in place, the reference doesn’t change, so nothing downstream is told:

items()[0].qty = 5;   // mutates the array element in place
total();              // still 124, function didn't rerun
TypeScript

That’s the actual output: the in-place change was invisible. The fix is to produce a new array with update():

items.update((list) =>
  list.map((i) => (i.name === 'Mouse' ? { ...i, qty: 3 } : i)),
);
TypeScript

The companion note on immutable collection updates goes deeper on this.

You can also give a computed a custom equal function. If names derives a list of strings and you pass equal: (a, b) => a.join() === b.join(), a price change produces a new array with the same names, the equality check says “same”, and anything reading names doesn’t recompute. In the test run, a downstream label computed did not rerun after a price-only update. Use this sparingly; it’s an optimisation, not a default.

Don’t copy derived values with effect()

A pattern you’ll see in code that migrated from RxJS or from ngOnChanges:

count = signal(0);
constructor() {
  effect(() => this.count.set(this.filtered().length));
}
TypeScript

It works, mostly, but it reintroduces the two-copies problem with extra timing on top. Effects run asynchronously as part of change detection, so there’s a moment where filtered has changed and count hasn’t. Anything reading both in that window sees them disagree. count = computed(() => this.filtered().length) has no such window, because it’s calculated on read.

Writing to a signal inside computed() itself is not allowed. Under Angular’s dev mode that’s error NG0600, “Signal write in a disallowed context”. The rule pushes you toward keeping derivation and mutation separate.

When the derived value must also be editable

Sometimes a value is mostly derived but the user can override it. A size picker defaults to the first available size, the user picks “L”, and if the product changes the choice should reset. A plain computed() can’t be set, and a plain signal() doesn’t reset. linkedSignal() covers this case; it’s stable in Angular 20 and was developer preview in 19:

const options = signal(['S', 'M', 'L']);
const chosen = linkedSignal(() => options()[0]);

chosen.set('L');                  // user override: chosen() is 'L'
options.set(['XL', 'XXL']);       // source changed: chosen() resets to 'XL'
TypeScript

Both lines printed what the comments say. Reach for linkedSignal only when you genuinely need the override. If the value is never set by hand, computed() is simpler and says more about your intent.

Interview practice

An interviewer gives you a product list with a text filter and asks for a “Showing 3 of 12 products” label. The current code keeps visibleCount in a signal and updates it in the filter input’s (input) handler. Testers report that after a product is deleted the label is wrong. How would you fix it, and why did the bug happen?

Answer

The bug happened because visibleCount is a copy of information that already exists in two other places, the product list and the filter text. The input handler keeps it in sync on one path, but deleting a product changes the list without going through that handler, so the copy goes stale. Any new path (loading from the server, undo, bulk delete) would have the same problem.

I’d keep only the true sources as writable signals, products and query, and derive everything else:

products = signal<Product[]>([]);
query = signal('');

visible = computed(() => {
  const q = this.query().trim().toLowerCase();
  return q ? this.products().filter((p) => p.name.toLowerCase().includes(q)) : this.products();
});
label = computed(() => `Showing ${this.visible().length} of ${this.products().length} products`);
TypeScript

Now there’s nothing to keep in sync. Deleting a product with products.update(list => list.filter(p => p.id !== id)) changes the source, and both visible and label recompute on their next read. I’d mention that computed is lazy and memoized, so the filter runs once per actual change, not on every change detection pass the way a template method would. I’d also point out that the delete must replace the array rather than splice it, otherwise the signal never notifies. If they push further and ask about an effect, I’d say an effect copying the length into another signal recreates the original bug with an extra timing gap.

Further reading

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
read ✓Angular · hard

Angular Resource API for Async Data

How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.

~6 min readread →
read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
esc