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.