Week 5: Signals Deep Dive

Signals are Angular's fine-grained reactivity primitive — the thing that decides exactly which piece of the DOM needs to update when a value changes, without re-checking everything else. You've used model() already; this week you learn the full signal toolkit properly.

Phase 2 of 7 Week 5 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model derived and effectful state with signals confidently
  • Avoid the common signal pitfalls — over-computing and effect loops
  • Decide, with reasons, when to reach for a signal vs. an RxJS stream

1. signal() Basics

A signal is a wrapper around a value that knows who's reading it. Call it like a function to read the current value; call .set() or .update() to change it.

signal-basics.ts
import { signal } from '@angular/core';

const count = signal(0);

console.log(count());     // 0 -- read by calling it

count.set(5);              // replace the value directly
count.update((n) => n + 1); // derive the new value from the old one

console.log(count());     // 6

That's the entire mental model: a signal is a box you read by calling, and write with set/update. What makes signals powerful is what happens next — anything that reads a signal inside a template, computed(), or effect() is automatically tracked as a dependent.

2. computed() — Derived State

computed() creates a read-only signal whose value is derived from others. It's lazy (only recalculates when actually read after a dependency changes) and memoized (reading it twice without a dependency changing doesn't re-run the calculation).

cart-totals.ts
import { signal, computed } from '@angular/core';

interface CartItem { price: number; quantity: number; }

const items = signal<CartItem[]>([
  { price: 12.5, quantity: 2 },
  { price: 8, quantity: 1 },
]);

const subtotal = computed(() =>
  items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);

const tax = computed(() => subtotal() * 0.08);
const total = computed(() => subtotal() + tax());

console.log(total()); // 34.83

items.update((list) => [...list, { price: 5, quantity: 3 }]);
console.log(total()); // recalculated automatically -- 50.83

Notice tax and total read other computed signals, not just raw ones — dependency tracking chains through as many layers as you need, and Angular only recomputes the ones actually affected by a given change.

3. effect() — Reacting to Changes

An effect runs a function once immediately, then re-runs it automatically whenever any signal it read changes. It's for side effects — logging, syncing to localStorage, imperative DOM work — not for deriving values (that's what computed() is for).

persist-cart.ts
import { signal, effect } from '@angular/core';

const cartItems = signal<CartItem[]>([]);

// Inside a component's constructor (effects need an injection context):
effect(() => {
  const items = cartItems(); // tracked -- effect re-runs when this changes
  localStorage.setItem('cart', JSON.stringify(items));
});

effect((onCleanup) => {
  const timer = setTimeout(() => console.log('idle'), 5000);
  onCleanup(() => clearTimeout(timer)); // runs before the next execution, and on destroy
});
The most common signals mistake

Reaching for effect() to keep one signal in sync with another. If you ever write effect(() => otherSignal.set(thisSignal())), stop — that's exactly what computed() is for. Effects are for leaving Angular's world (the DOM, storage, logging); computed is for staying inside it.

4. linkedSignal() for Dependent, Resettable State

Sometimes you need state that's writable — the user can change it directly — but should automatically reset when some other signal changes. computed() can't do this (it's read-only); a plain signal() plus a manual effect() to reset it works but is easy to get wrong. linkedSignal() is built exactly for this.

selected-tab.ts
import { signal, linkedSignal } from '@angular/core';

const availableTabs = signal(['overview', 'details', 'reviews']);

// Defaults to the first tab -- but resets to the first tab again
// whenever `availableTabs` changes, even after the user picked a different one
const selectedTab = linkedSignal(() => availableTabs()[0]);

selectedTab.set('reviews'); // user clicked a tab -- writable, like a signal

availableTabs.set(['summary', 'pricing']);
console.log(selectedTab()); // 'summary' -- automatically reset, 'reviews' no longer valid

Without linkedSignal(), changing availableTabs here would leave selectedTab pointing at a tab that no longer exists — a real bug you'd otherwise have to guard against by hand in an effect.

5. Equality Functions & untracked()

By default, signals compare new and old values with === — setting an object to a different-but-equal-looking object still counts as a change. A custom equality function lets you change that:

custom-equality.ts
import { signal } from '@angular/core';

interface Point { x: number; y: number; }

const position = signal<Point>(
  { x: 0, y: 0 },
  { equal: (a, b) => a.x === b.x && a.y === b.y }
);

position.set({ x: 0, y: 0 }); // new object, but "equal" by our function --
                                // dependents are NOT notified

untracked() lets you read a signal without registering it as a dependency — useful inside an effect when you need a value but shouldn't re-run just because that particular value changed:

untracked.ts
import { signal, effect, untracked } from '@angular/core';

const query = signal('');
const analyticsEnabled = signal(true);

effect(() => {
  const q = query(); // tracked -- effect reruns when the search query changes
  if (untracked(() => analyticsEnabled())) { // read, but not tracked
    console.log('search:', q);
  }
});

Without untracked() there, toggling analyticsEnabled alone would also re-run the effect — usually not what you want.

6. Signals vs. RxJS — When to Reach for Each

Both model changing values over time, but they answer different questions. Signals answer "what is the current value?" — synchronous, always has a value, read by calling. RxJS Observables answer "what's the sequence of events over time?" — asynchronous by nature, can represent things that aren't really a "current value" at all (a stream of button clicks, WebSocket messages).

  • Component state, derived UI values, form field values → signals
  • HTTP requests, debounced search input, WebSocket streams, complex async pipelines → RxJS

You won't have to choose blind for long — Week 16 covers toSignal() and toObservable(), the two functions that convert between them, so you can use RxJS for the async plumbing and hand a plain signal to your template at the boundary.

7. Hands-on Exercise

Hands-on

Build a shopping-cart state model entirely with signals

Extend Section 2's cart example into a small, complete state model.

Requirements:

  1. A items signal holding an array of { id, name, price, quantity }.
  2. Computed signals for subtotal, a 10% discount that only applies when subtotal exceeds $50, and total (subtotal minus discount, plus 8% tax on the discounted amount).
  3. Methods on a small cart service class — addItem, removeItem, setQuantity — that mutate the items signal via .update(), never .set() with a hand-built array copy-paste.
  4. An effect() that persists items to localStorage on every change, and reads it back to initialize the signal on startup.
  5. A itemCount computed signal used in a small badge component, proving derived signals compose across component boundaries when passed as inputs.
Hint

If the persistence effect fires immediately on load and overwrites the localStorage value you just read, you've got the read/write order backwards — initialize the signal from storage first, then set up the effect that writes back to it.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is computed() described as "lazy" and "memoized" — what would break without those properties?

Lazy means it doesn't recalculate the instant a dependency changes — only when something actually reads it next. Memoized means reading it repeatedly without a dependency change returns the cached value instead of recomputing. Without both, an app with several chained computed signals would recalculate the same derived values repeatedly on every read, even when nothing relevant changed — wasted work that gets expensive fast in a large component tree.

Q2

Why is effect(() => otherSignal.set(thisSignal())) considered an anti-pattern?

It's using a side-effect tool to do pure derivation, which computed() already does — more efficiently, and without risking effect ordering issues or accidental update loops. If otherSignal is ever read somewhere that also feeds back into thisSignal, this pattern can cause infinite update cycles that a computed signal structurally can't.

Q3

What problem does linkedSignal() solve that a plain signal() can't?

State that needs to be both independently writable (the user can change it) and automatically reset to a derived default when some other signal changes. A plain signal has no built-in way to react to another signal; you'd need a manual effect to reset it, which is easy to get wrong (ordering, missed cases). linkedSignal() bakes that reset logic in directly.

Q4

You need a debounced, cancellable search-as-you-type request. Signal, or Observable — and why?

Observable. Debouncing and cancellation are exactly what RxJS operators (debounceTime, switchMap) are built for — modeling a sequence of events over time, where earlier in-flight work needs to be discarded. Signals model "the current value," not a stream of async events, so they're the wrong tool for the async orchestration itself (though you'd likely convert the final result to a signal with toSignal() for the template).