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.
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).
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).
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
});
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.
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:
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:
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
Build a shopping-cart state model entirely with signals
Extend Section 2's cart example into a small, complete state model.
Requirements:
- A
itemssignal holding an array of{ id, name, price, quantity }. - Computed signals for
subtotal, a 10%discountthat only applies whensubtotalexceeds $50, andtotal(subtotal minus discount, plus 8% tax on the discounted amount). - Methods on a small cart service class —
addItem,removeItem,setQuantity— that mutate theitemssignal via.update(), never.set()with a hand-built array copy-paste. - An
effect()that persistsitemstolocalStorageon every change, and reads it back to initialize the signal on startup. - A
itemCountcomputed signal used in a small badge component, proving derived signals compose across component boundaries when passed as inputs.
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?
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?
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?
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?
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).