Angular Signals: The Producer/Consumer Graph and Glitch-Free Reactivity [2026]
Signals are the reactive primitive Angular added in v17 and built the rest of the modern framework on. Inputs are signals (lesson 2.4). Queries are signals (lesson 2.13). Change detection is signal-driven (lesson 3.5). Understanding how the signal graph actually works — the producer/consumer model, automatic dependency tracking, the glitch-free guarantee — is what separates using signals from debugging them.
This is lesson 3.1, opening Module 3. The next seven lessons build on this one. We will start with the three primitives (signal, computed, effect), then walk the producer/consumer dependency graph that ties them together.
The three primitives #
Angular's signal system has three building blocks:
| Primitive | Role | Example |
|---|---|---|
signal<T>() |
A writable producer of values | count = signal(0) |
computed<T>() |
A read-only consumer that derives from other signals | doubled = computed(() => count() * 2) |
effect() |
A side-effect that runs when its tracked signals change | effect(() => console.log(count())) |
Reads use () (call the signal). Writes use .set(...), .update(...), or .mutate(...).
import { signal, computed, effect } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
const eff = effect(() => {
console.log(`count=${count()}, doubled=${doubled()}`);
});
count.set(5); // logs: count=5, doubled=10
count.update(n => n + 1); // logs: count=6, doubled=12
The effect() saw both reads when it first ran, and Angular wired up its dependencies automatically — no manual subscription, no observable chain.
Producers and consumers #
Every signal is one of two things at any moment:
- A producer — its value is the source of truth
- A consumer — it reads other producers
signal() is a pure producer (nobody computes it; you set it).
computed() is BOTH a consumer (reads other signals) AND a producer (other signals/effects can consume it).
effect() is a pure consumer (it cannot be read).
The relationships form a directed graph — values flow from producers down through computed chains into effects. Angular's job is to keep that graph consistent.
Automatic dependency tracking #
When a computed() or effect() runs its body, Angular records every signal that was READ during execution. Those become the function's dependencies. The next time any of them changes, the computed/effect re-runs.
const a = signal(1);
const b = signal(2);
const showA = signal(true);
const result = computed(() => showA() ? a() : b());
result(); // 1 (depends on showA + a, NOT b)
showA.set(false);
result(); // 2 (now depends on showA + b, NOT a)
a.set(99); // result does NOT re-evaluate — a is no longer a dependency
This is dynamic dependency tracking — the dependency set is recomputed each run. A change to a after showA.set(false) is ignored because a was not read in the latest run.
No equivalent in RxJS — observables have fixed dependency chains decided when you build the pipe. Signals figure it out each time.
Push vs pull — laziness #
A computed() is lazy. Its function does not run until someone reads its value. When a dependency changes, the computed marks itself "dirty" but does not re-evaluate; the next read triggers the computation.
const a = signal(0);
const doubled = computed(() => {
console.log('computing');
return a() * 2;
});
// no log yet — never read
a.set(5); // marks doubled dirty, but does not run the function
a.set(10); // still no log
doubled(); // logs 'computing', returns 20
doubled(); // returns 20 from cache — no log
Laziness is what makes computed() cheap. Even if you have hundreds of derived signals, only the ones actually read each render do work. The downside: the function is not guaranteed to have run by the time a write returns.
Effects are NOT lazy — they always re-run when their dependencies change (more on this in lesson 3.2).
The glitch-free guarantee #
When multiple signals change in a batch (e.g., one effect causes another), Angular waits for all writes to settle before re-running consumers. The result: no intermediate "inconsistent" values appear.
const a = signal(1);
const b = signal(10);
const sum = computed(() => a() + b());
let log: number[] = [];
effect(() => log.push(sum()));
// All three set inside a single batch
batch(() => {
a.set(2);
b.set(20);
});
log; // [11, 22] — NOT [11, 12, 22]
Without glitch-free behavior, the effect would fire twice: once after a.set(2) (showing 12), once after b.set(20) (showing 22). With it, the effect fires exactly once, observing the final state. This is what distinguishes Angular's signals from the classic observable-everywhere approach.
Equality checks: avoiding spurious updates #
When you .set(value), Angular runs an equality check against the current value. If the new value is === equal, the signal does NOT notify dependents. This prevents "set the same value" from triggering a needless re-render storm.
const count = signal(5);
count.set(5); // no-op — same value, no notification
count.set(6); // notifies
The default equality is Object.is() (essentially ===). For object signals where you want a deep equality check, pass a custom one:
const user = signal(
{ id: 1, name: 'Pradeep' },
{ equal: (a, b) => a.id === b.id && a.name === b.name },
);
Most code does not need custom equality. The default is correct for primitives. For objects/arrays, replacing the reference is the idiomatic update — and the default Object.is() correctly treats the new reference as a change.
Reading vs writing — explicit verbs #
Writing is explicit — .set(), .update(), .mutate(). The three differ in shape:
const counter = signal(0);
const items = signal<string[]>([]);
// .set(value) — replace with a new value
counter.set(5);
// .update(fn) — derive new value from current
counter.update(n => n + 1);
// .mutate(fn) — modify in place. DEPRECATED in v17 — avoid
// items.mutate(arr => arr.push('foo'));
// Modern pattern — replace the array reference
items.update(arr => [...arr, 'foo']);
Do not use .mutate(). It survived from early experimental versions but is now deprecated in v17+. Always replace the reference with .update(...) returning a new array/object. This keeps Angular's equality check honest and integrates correctly with OnPush change detection.
untracked() — opt out of tracking #
Sometimes a computed/effect needs to read a signal without subscribing to its changes. untracked() does that:
const count = signal(0);
const limit = signal(100);
effect(() => {
// Re-runs whenever count changes, but NOT when limit changes
if (count() > untracked(limit)) {
alert('Over limit');
}
});
Use cases:
- Reading a configuration signal you do not want to react to
- Avoiding cyclic dependencies (effect that reads + writes the same signal)
- Snapshotting a value for logging without subscribing
Lesson 3.2 covers untracked() in more depth alongside other effect mechanics.
When NOT to use signals #
Three cases where signals are the wrong tool:
- Streams over time — a series of timestamped values (mouse moves, WebSocket messages). Use RxJS Observables, then bridge with
toSignal()if needed (lesson 7.3). - Cancellation-heavy async — HTTP with replaceable in-flight requests. The
resource()API (lesson 6.3) wraps this for you. - Cross-component event broadcasting — one component emits, many components listen. Outputs (lesson 2.5) or RxJS Subjects fit this better.
For everything else — state, derived state, side effects on state — signals are the right primitive.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
Reading signal outside reactive context warning |
Read a signal in a place Angular cannot track | The signal still works; the warning is for performance-sensitive cases |
effect() outside injection context |
Called effect() in a setTimeout or async callback |
Move to constructor, or wrap with runInInjectionContext(injector, () => effect(...)) |
| Array mutations don't trigger updates | Used .push() instead of .update(arr => [...arr, x]) |
Replace the reference, do not mutate in place |
computed() doesn't re-evaluate |
Nobody reads it (laziness) | Read the computed from a template, an effect, or another computed |
| Cyclic dependency in effect | Effect writes to a signal it also reads | Use untracked() for the read, or refactor the data flow |
What's next #
Lesson 3.2 dives into effect() and computed() in depth — timing, cleanup, equality, untracked(). Lesson 3.3 covers model() (two-way binding primitive). Lesson 3.4 walks debounced(). Lessons 3.5 and 3.6 cover zoneless change detection. Lesson 3.7 is the signal-vs-observable decision tree. Lesson 3.8 closes Module 3 with linkedSignal() for dependent state.
By the end of Module 3 you will be able to architect any application state using signals as the default primitive, with RxJS as the deliberate opt-in for the cases that need it.
Try it yourself #
The smallest demo of all three primitives wired together:
import { signal, computed, effect } from '@angular/core';
const price = signal(99.99);
const quantity = signal(1);
const total = computed(() => price() * quantity());
const formatted = computed(() => `$${total().toFixed(2)}`);
effect(() => console.log(`Total: ${formatted()}`));
quantity.set(3); // Total: $299.97
price.set(price() * 0.9); // Total: $269.97 (10% discount)
Two writable signals, two derived computeds, one effect. Modify any input — the chain propagates exactly once, exactly correctly. No subscriptions to manage, no manual dirty-flagging.
get_best_practicesYes for any state the template renders or that other components/services react to. Local computation variables, function arguments, and intermediate values stay as regular variables — no need to wrap them in signal(). The rule: if reading it should trigger re-rendering or downstream re-computation, it’s a signal. If it’s purely internal computation, it’s a regular variable. HttpClient still returns Observables — bridge to signals with toSignal() at the component boundary.Lesson 3.2 picks up the timing rules for effect() and the equality model in computed().
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


