Angular Signals: The Producer/Consumer Graph and Glitch-Free Reactivity [2026]

Link copied
Angular Signals: The Producer/Consumer Graph and Glitch-Free Reactivity [2026]

Angular Signals: The Producer/Consumer Graph and Glitch-Free Reactivity [2026]

Angular Tutorial Module 3: Signals Lesson 3.1

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:

  1. Streams over time — a series of timestamped values (mouse moves, WebSocket messages). Use RxJS Observables, then bridge with toSignal() if needed (lesson 7.3).
  2. Cancellation-heavy async — HTTP with replaceable in-flight requests. The resource() API (lesson 6.3) wraps this for you.
  3. 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.

YouShould every piece of component state be a signal in 2026?
Claude · used 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

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *