Most state in an application is not stored; it is worked out from state that is. Signals have a dedicated tool for that, and using it removes a whole category of bug.
computed()
readonly totals = computed(() => calculateTotals(this._items(), this._orderType()));
readonly itemCount = computed(() => this.totals().itemCount);The cart's totals are derived, never stored. There is one source of truth — the items and the order type — and the subtotal, tax, delivery fee and total are all functions of it. A stored total is a second source of truth that can disagree with the first, and eventually will.
Two properties matter:
Dependencies are tracked automatically. Reading this._items()
inside the function is what subscribes it. There is no dependency array, so there is no way to
forget one — which is the failure mode useMemo is famous for.
It is lazy and cached. The function does not run when the dependencies change;
it runs when someone next reads the computed, and only if a dependency actually changed
since. Nothing reading totals() means calculateTotals never runs. And
itemCount computed from totals — a computed reading a computed — is
entirely normal.
⚠️ A computed must be pure. No writing to signals, no HTTP calls, no
console.log you rely on. It may be skipped, or run at a moment you did not predict,
because the framework decides when. Anything with a side effect belongs in an
effect.
effect()
An effect runs a side effect when the signals it read last time change:
const ref = effect(() => {
if (this.menu.loading()) return;
// Read the catalogue now, while inside the reactive context, then leave it.
const products = this.menu.products();
const crusts = this.menu.crusts();Again there is no dependency array — reading menu.loading() is what subscribes
it. That is the whole API.
Effects run after change detection, and they are batched: several signals changing in one tick produce one run.
onCleanup
The effect callback receives a cleanup registrar, which runs before the next run and on destroy. The cart uses it to debounce writes to the server:
const timer = setTimeout(() => void this.save(items, orderType), 300);
onCleanup(() => clearTimeout(timer));Clicking "+" three times quickly is one write, not three: each change cancels the pending
timer before scheduling a new one. It is the return-a-function cleanup of a React
useEffect, under a different name.
Cleanup of the effect itself
this.destroyRef.onDestroy(() => ref.destroy());An effect created in a component or service's injection context — a field initialiser
or the constructor — is destroyed with it automatically. This one is created inside a method called
from the constructor, so it is tied to the service's lifetime explicitly. If you ever call
effect() outside an injection context you must pass an injector or keep the handle;
otherwise it outlives its owner.
The rule about writing to signals
Writing to a signal inside an effect is where infinite loops come from: the write changes a
signal, which re-triggers the effect, which writes again. Angular used to throw on this by default.
It is now allowed and still worth avoiding — if an effect writes a signal, ask whether the value is
actually derived, in which case it wants to be a computed.
The cart shows the disciplined version of the unavoidable case. Its effects need flags, and those flags are plain fields, not signals:
/** Guards the hydrate effect. A plain field, so setting it cannot re-trigger the effect. */
private hydrationStarted = false;The comment says exactly why. Reading a signal inside an effect subscribes to it, so guarding on a signal and then setting it would schedule the effect to run a second time only to discover it has nothing left to do. The same reasoning applies to the cart id:
private cartId: string | null = cartIdStore.get();The persist effect needs the current cart id but must not re-run when it is assigned — that
would fire an extra PUT immediately after the cart is created. A signal read inside an effect becomes
a dependency; a plain field does not. It is the direct equivalent of a useRef, for
exactly the same reason.
Which one to reach for
Deriving a value? computed. Always, and it is almost always what
you want.
Reaching outside Angular? effect. Writing to
localStorage, syncing to a server, driving a third-party library, logging.
If you are reaching for an effect to keep one piece of state in step with another, that is the
signal to stop: the second piece is derived, and a computed will do it without the
synchronisation bug you are about to write.
linkedSignal
The case that genuinely falls between them: a value derived from another, but locally
overridable — a selected size that follows the product until the user picks one. computed
cannot do it because it is read-only, and an effect writing a signal is the bug above.
linkedSignal is built for it. The pizza app never needed one, which is worth knowing:
most apps do not.
What is next
The lifecycle hooks — and, more usefully, how few of them you still need.