Angular Signals and State Management: signal, computed, effect, and Stores
Signals are the biggest change in Angular’s history — a rebuilt reactive core that every recent feature (signal inputs, resources, zoneless change detection) stands on. You’ve used them throughout this series; this guide explains the system itself: how dependency tracking actually works, the three primitives’ distinct roles, the anti-patterns that mark half-understood signals, and the store pattern that has become modern Angular’s default state management.
The Primitive: A Value That Knows Its Readers
const count = signal(0);
count(); // read: 0count.set(5); // replacecount.update((c) => c + 1); // transform: 6A signal is a container with a twist: reading it inside a reactive context registers a dependency. When a template renders {{ count() }}, Angular records “this view depends on count” — and when count changes, it knows exactly which views, computeds, and effects to update. No diffing the world, no guessing: a live dependency graph, maintained automatically by the act of reading.
That’s the entire trick, and it’s worth contrasting with what it replaced. Classic Angular (zone.js) assumed anything might have changed after every event and re-checked everything — the change-detection guide tells that story. Signals invert it: nothing is checked unless a signal it read has changed. Fine-grained, not app-grained.
One rule of hygiene inherited from immutability discipline: update by replacement, not mutation.
items.update((xs) => [...xs, newItem]); // ✓ new reference — dependents notifieditems().push(newItem); // ✗ mutation — signal never knowsSignals compare by reference (configurable via equal); mutating the inner object changes nothing the signal can see. Spread-based updates aren’t style points here — they’re the notification mechanism.
computed: Derive, Don’t Synchronize
const items = signal<CartItem[]>([]);const memberDiscount = signal(0.1);
const subtotal = computed(() => items().reduce((s, i) => s + i.paisePerUnit * i.qty, 0));const total = computed(() => Math.round(subtotal() * (1 - memberDiscount())));computed declares a value as a pure function of other signals: lazily evaluated, cached until a dependency changes, chainable into graphs (total depends on subtotal depends on items). The properties that matter:
- It can never be stale. There is no code path where
itemschanges andsubtotaldoesn’t — becausesubtotalisn’t stored, it’s defined. Compare the hand-synced alternative (this.subtotal = recompute()sprinkled after every mutation) — the bug class where one mutation site forgets, and the UI lies. Derivation deletes the class. - Dependencies are tracked dynamically — whatever the function actually read this run. A
computedwith branches (isVip() ? vipPrice() : basePrice()) depends only on the branch taken, and re-tracks on each evaluation. - Computeds are read-only — no
.set. If you feel the need to write to one, you’re looking for asignal(source state) orlinkedSignal(below).
The design mantra that falls out, and it’s the highest-value sentence in this guide: store the minimum; derive the rest. Given items, everything — counts, totals, filtered views, validity — should be computed. Every derived value you store instead is a synchronization job you’ve assigned to your future self.
linkedSignal covers the edge where derivation and writability collide — “resets when its source changes, but user-adjustable in between” (selected item resets when the list reloads; quantity resets when the product changes):
const selected = linkedSignal(() => items()[0] ?? null); // writable, re-derives on items changeeffect: The Exit Door, Used Sparingly
constructor() { effect(() => { localStorage.setItem('cart', JSON.stringify(this.items())); });}effect runs a side-effecting function whenever its signal dependencies change — the bridge out of the reactive graph to the imperative world: storage, logging, analytics, imperative DOM libraries, title updates. Effects auto-track like computeds, run after change detection, and self-dispose with their context.
The discipline section, because effect misuse is the signals anti-pattern:
// ✗ deriving state in an effect — the cardinal sineffect(() => this.total.set(this.price() * this.qty()));// ✓ that's a computed:total = computed(() => this.price() * this.qty());Effect-derived state reintroduces everything computeds eliminated: ordering questions, glitch windows where total is momentarily stale, and write-conflicts. The test before writing any effect: “am I leaving the signal world?” If the answer is “no, I’m producing another value” — it’s a computed (or linkedSignal). Real codebases end up with dozens of computeds and a handful of effects; the inverse ratio is the smell. (Guardrail trivia: writing signals inside effects requires opting in precisely because the framework wants you to feel the friction.)
The Store Pattern: App State, Solved Simply
Scale the primitives up and you get modern Angular’s answer to “do I need NgRx?” — for most apps, a signal store service:
@Injectable({ providedIn: 'root' })export class OrdersStore { private api = inject(OrderApi);
// private writable source of truth private state = signal<{ orders: Order[]; filter: OrderStatus | 'all' }>({ orders: [], filter: 'all', });
// public read-only projections readonly filter = computed(() => this.state().filter); readonly visible = computed(() => { const { orders, filter } = this.state(); return filter === 'all' ? orders : orders.filter((o) => o.status === filter); }); readonly pendingCount = computed(() => this.state().orders.filter((o) => o.status === 'pending').length);
// named mutations — the only write paths setFilter(filter: OrderStatus | 'all') { this.state.update((s) => ({ ...s, filter })); } load() { this.api.list().subscribe((orders) => this.state.update((s) => ({ ...s, orders }))); } markShipped(id: string) { this.state.update((s) => ({ ...s, orders: s.orders.map((o) => (o.id === id ? { ...o, status: 'shipped' } : o)), })); }}Components inject and read — store.visible() in templates, store.setFilter(...) on clicks — and every consumer updates reactively, forever. The structure is doing real work: private writable signal (nobody outside can mutate state — encapsulation), public computeds (consumers can’t hold stale copies), named mutation methods (every state change has a greppable name and one implementation — poor-man’s actions). This shape — sometimes formalized via the NgRx SignalStore library, often hand-rolled exactly as above — has become the community default. Reach for full NgRx (actions/reducers/devtools) when you genuinely need its event log and middleware: large teams, audit-grade traceability, complex effect orchestration.
Where async fits: resources for read-flows keyed on state, subscribe-and-update (as in load) for commands — the reads-declarative, writes-imperative split operating inside the store.
How the Tracking Actually Works (The Interview Cut)
The mechanism is simple enough to hold entirely, and holding it dissolves several mysteries. When a reactive consumer (a template, a computed, an effect) runs, the framework sets a global “currently evaluating” marker; every signal() read during that run checks the marker and records the edge consumer → this signal. When a signal is written, it walks its recorded consumers and marks them dirty; templates schedule re-render, computeds mark themselves stale (recomputing lazily on next read), effects queue.
Consequences that now explain themselves:
- Untracked reads exist — a signal read inside a
setTimeoutcallback or event handler registers nothing (no evaluation marker is set), which is why “I read the signal in a click handler and nothing re-runs” is correct behavior, not a bug: handlers react to events, templates react to signals.untracked(() => sig())makes the same choice explicit inside reactive contexts — read without depending, occasionally right in effects that log state without wanting to re-run on it. - Conditional dependencies are real —
computed(() => a() ? b() : c())re-records edges each run, so whilea()is true, changes tocare invisible. Dynamic tracking is a feature (minimal graphs) with a corollary: the dependency set is whatever ran last time, not whatever appears in the source. - Glitch-freedom — because computeds pull lazily rather than push eagerly, a change to
itemswith three dependent computeds never lets a template observe a half-updated intermediate state; everything reads consistently within a change-detection pass. This is the property effect-derived state sacrifices, completing the case against it.
Interviewers increasingly probe signals; narrating reader-registers-dependency, writer-notifies-readers, computeds-pull-lazily — and knowing why handler reads don’t track — places you well past tutorial familiarity.
Frequently Asked Questions
Signals vs RxJS — which do I learn, which wins? Both, for different axes: signals model state (current values, synchronous reads, no subscription management); RxJS models events over time (streams, debouncing, cancellation, the next guide). Modern code: signals by default, RxJS where time/coordination is intrinsic, toSignal/toObservable as the bridges. The “war” framing is obsolete — the framework itself uses both.
Why call syntax — count() — instead of plain properties? The call is the dependency-tracking hook (and you know functions-as-values). Property magic (proxies) hides reads; the call makes every reactive read visible and greppable. You stop noticing within a week.
Do signals replace component @Inputs, lifecycle, etc.? They already did — signal inputs, queries, resources are signal-based; lifecycle hooks shrank because derivations subsume them. This guide is the theory behind the series’ practice.
How granular should signals be — one big state object or many small signals? Either works (the store above uses one object + projections; many small signals is equally valid). Optimize for update ergonomics: state that changes together lives together; independent state in independent signals avoids spurious recomputation of unrelated computeds.
Can I use signals outside components — plain classes, utilities? Yes — signals are a standalone reactive system (only effect needs an injection context, or an explicit injector). Stores, directives, route guards: all signal-friendly.
Migrating a Component to Signals: A Checklist
Since most working developers meet signals mid-codebase rather than greenfield, the incremental recipe: (1) State fields → signal() — every mutable property templates read; mutations become .set/.update with spread-replacement. (2) Derived fields → computed() — anything currently recomputed in getters, methods, or sync-blocks; delete the synchronization sites as you go (the deletion is the payoff). (3) @Input() → input() — usually mechanical (ng generate @angular/core:signal-input-migration automates the bulk); ngOnChanges bodies become computeds/effects. (4) Subscriptions feeding fields → toSignal or resources. (5) Only then consider OnPush/zoneless for the component — with everything signal-driven, it’s a flag flip rather than a bug hunt. Components migrate independently — signal and non-signal components interoperate freely — so the strategy is opportunistic: migrate what you touch, prioritize the components profiling implicates, and leave stable legacy alone. The one migration to not defer: new code starts signal-first, because every zone-reliant component written today is tomorrow’s checklist item.
A closing calibration on what signals don’t solve: they’re a reactivity primitive, not a data-management framework. Cache invalidation policy, optimistic updates with rollback, offline sync, undo/redo — these remain design problems you solve on top of signals (or adopt a library for), and pretending the primitive answers them is how “signals will fix our state” projects disappoint. What signals do guarantee — derived values that can’t go stale, precise updates, and a readable dependency graph — removes the accidental complexity so the essential complexity gets your full attention. That’s the honest pitch, and it’s plenty.
Self-Check
cart.items().push(item)— the badge doesn’t update. Mechanism, and fix?- Convert to signals: a component stores
filteredList, updating it in three places wheneverlistorquerychanges. effect(() => { if (this.user()) this.greeting.set('Hi ' + this.user().name); })— critique and rewrite.- In the OrdersStore, why is
stateprivate with computeds public — name the two failure modes it prevents. - A “selected order” should reset to null whenever the orders list reloads, but be user-settable otherwise. Which primitive?
(Answers: mutation doesn’t change the reference, so no notification — update with spread. filteredList = computed(() => filter(this.list(), this.query())) — delete all three sync sites. State-derivation-in-effect: greeting = computed(() => this.user() ? 'Hi ' + this.user().name : ''). Outside mutation bypassing invariants, and consumers caching stale snapshots — both structurally impossible here. linkedSignal(() => null, ...) keyed on the orders signal.)
Next: RxJS Essentials — the observables you’ll actually meet in Angular, the five operators that matter, and the signal-observable bridges.