GAP·MAP

Angular change detection & signals

frontend · Angularmiddlesenior~8 min read

covers zone.js change detection · OnPush & its triggers · signals (signal/computed/effect) · zoneless change detection · template performance traps

[ middle depth ]

How Angular knows to re-render

Angular does not watch your data. It re-checks the component tree, and something has to tell it when. In a classic app that something is zone.js. Zone.js monkey-patches the async browser APIs (setTimeout, setInterval, Promise, addEventListener, XHR) before your code runs. Your whole app runs inside a zone called NgZone, so every async callback passes through the patched version. When a callback finishes and the microtask queue drains, NgZone emits onMicrotaskEmpty, and ApplicationRef.tick() walks the tree from the root and re-checks every component.

01async callback runs02microtask queue empties03ApplicationRef.tick()04check the tree top-down05update the DOM
fig · How a zone.js tick runs change detection

That is why a click, a timer, or an HTTP response makes the view update without you asking. It is also why an unrelated mousemove handler can trigger a full pass: zone.js patched the listener, so any handler ending its task wakes the same global check.

With the default change detection strategy every component is checked on every tick. That is simple and usually fine, but on a large tree with frequent async events it does real work you may not need.

OnPush and when it actually re-checks

ChangeDetectionStrategy.OnPush narrows when a component gets checked. The global tick still runs, it just skips an OnPush subtree unless one of these happens:

@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* ... */ })
  • an @Input() gets a new value by reference (Angular compares old and new input by identity),
  • an event is handled inside the component or one of its children (click, output, @HostListener),
  • someone calls ChangeDetectorRef.markForCheck(),
  • the AsyncPipe receives a new value (it calls markForCheck for you).

markForCheck does not check immediately. It marks this component and its ancestors up to the root as needing a check, and the next change detection run does the work.

Wrong "OnPush turns change detection off for the component." It does not. It changes the trigger. The component still checks, just on the four events above instead of on every tick.

Wrong "I updated the object, so an OnPush child will update." If you mutate a field on the same object (user.name = 'Ada') the reference is unchanged, so OnPush sees no new input and skips the child. Right replace the value immutably so the reference changes, or model it as a signal:

// mutation the OnPush child never sees:
this.user.name = 'Ada';
// new reference the input comparison catches:
this.user = { ...this.user, name: 'Ada' };

Signals: the value that tells Angular it changed

A signal is a value that knows who reads it. You create one, read it by calling it, and write it with set or update:

count = signal(0);
doubled = computed(() => this.count() * 2); // derived, read-only
this.count.set(5);
this.count.update(n => n + 1);

computed derives a value from other signals. It is lazy and memoized: it recomputes the next time you read it after a dependency changed, so writing the source marks it stale without recomputing anything yet. Equality is Object.is by default, so setting a signal to a value equal to its current one notifies nobody.

The reason signals matter for rendering: when you read a signal in a template, Angular records it as a dependency of that view. When the signal changes, Angular marks the component automatically. In an OnPush component this replaces most manual markForCheck calls. Read the signal in the template, write it anywhere, and the view updates.

effect runs a side effect when the signals it reads change:

constructor() {
  effect(() => console.log('count is', this.count()));
}

An effect runs at least once and again whenever a tracked signal changes. Use it for things outside Angular's own rendering (logging, a canvas draw, syncing to localStorage), not for computing derived state. To derive state from state, reach for computed.

Two template traps that cost you every pass

Calling a method in a binding runs that method on every change detection pass:

<!-- getRows() re-runs on every pass, however often the pass fires -->
<tr *ngFor="let r of getRows()">...</tr>

Angular cannot tell that getRows() would return the same thing, so it calls it again each pass to be safe. On a busy tree that adds up fast. Move the work out of the template: a computed signal, a field you recompute when inputs change, or a pure pipe (pure pipes re-run only when their inputs change).

The second trap is mutating inputs, covered above: it breaks OnPush silently because the reference never changes. Prefer immutable updates or signals for anything an OnPush child reads.

[ senior depth ]

What a change detection pass actually does

ApplicationRef.tick() starts at the root view and checks components top-down. For each checked component Angular evaluates the template bindings, compares each against its last value, and updates the DOM where they differ. Default-strategy components are checked on every pass. OnPush components carry a flag: the pass checks them only when they are marked dirty.

markForCheck is what sets that flag, and it sets it on the whole path from the component up to the root, so an ancestor pass can reach it. It does not check anything itself; it schedules. The AsyncPipe calls it on every emission, which is why obs$ | async updates an OnPush view without any manual wiring.

For hot spots you can take a component out of the automatic tree entirely:

constructor(private cdr: ChangeDetectorRef) {
  this.cdr.detach(); // this view is no longer checked on tick()
}
tickManually() {
  this.cdr.detectChanges(); // check this view and its children, now
}

detach plus detectChanges is local change detection: you own when this subtree runs. It is a scalpel for a component that updates thousands of times a second, not a default. reattach puts it back.

Signals as a reactive graph

Signals are a dependency graph with a push/pull split. Writing a signal pushes a "you are stale" notification to its dependents but computes nothing. Reading a computed pulls: it checks whether any dependency changed and recomputes only if so, then caches. Because values are pulled lazily and memoized, a reader never observes a half-updated intermediate state where one computed reflects the new input and another still reflects the old. That is the glitch-free property, and it falls out of the pull model rather than being bolted on.

Two consequences worth holding onto. First, a computed that nobody reads does no work, so its cost follows how often you read it. Second, equality is Object.is by default: setting a signal to a value equal to its current one propagates nothing, and returning a new object from a computed every time defeats the memoization downstream. Supply a custom equal when you derive objects that are logically stable.

Effects: timing and the state-propagation trap

effect does not run synchronously when you call set. Effects run asynchronously as part of change detection, and they run at least once, then again when a tracked signal changes. A standard effect runs before Angular commits the DOM; afterRenderEffect runs after the DOM is written, which is where you touch real elements.

Wrong "I set the signal, so the effect already logged before the next line." The effect is scheduled to run later. Reads after an await inside the callback are not tracked either, because the reactive context is only active for the synchronous portion.

The failure mode seniors get probed on is using effects to propagate state:

// anti-pattern: writing a signal from an effect to "keep things in sync"
effect(() => this.total.set(this.a() + this.b()));
// right: derive it
total = computed(() => this.a() + this.b());

The docs are explicit that writing state from effects risks ExpressionChangedAfterItHasBeenChecked errors, circular updates, and extra change detection cycles. Derived state is a computed. Effects are for the non-reactive edges: logging, a third-party chart, localStorage, imperative DOM. They need an injection context, or an explicit Injector passed in the options if you create one outside a constructor, and the callback can take an onCleanup to tear down before the next run or on destroy.

Going zoneless

provideZonelessChangeDetection() is stable as of Angular v20.2 and the default for new apps from v21 (current line is v22). It removes zone.js from the bundle. The scheduler no longer learns about async work by patching timers and events; it runs a check only when something notifies it explicitly:

  • a signal that a template reads is updated,
  • ChangeDetectorRef.markForCheck runs (so the AsyncPipe still works),
  • ComponentRef.setInput sets an input,
  • a bound template or host listener callback fires,
  • a view marked dirty by one of the above is attached.

Change detection does not stop under zoneless; the trigger source narrows to that list. OnPush is not required but recommended, because a zoneless app leans on the same "mark dirty" mechanism OnPush already uses.

The migration failure mode is code that quietly depended on zone.js. A field written inside setInterval, a setTimeout, or a non-Angular event callback used to render because the tick fired anyway. Remove zone.js and nothing notifies the scheduler, so the view goes stale with no error. The fix is to route that state through a signal (or the AsyncPipe, or an explicit markForCheck). Auditing for raw field writes in async callbacks is the bulk of a real zoneless migration, which is why teams move to signals first and flip the provider last.

Where the time actually goes

Before reaching for OnPush or zoneless, find what each pass costs. Method calls in templates ({{ compute() }}) re-run on every pass because Angular cannot prove the result is stable; strategy only changes how often the pass hits the component; the method still re-runs each time it does. Move computation to computed signals or pure pipes so it runs only when its inputs change. Mutated object inputs are the other recurring bug: they change no reference, so OnPush skips them and the view lies about its data. Signals plus OnPush is the combination that holds up: a signal read marks its component precisely, cheap derivations stay memoized, and the same code needs no rework when zone.js comes out.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the Angular change detection & signals notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more Angular

was this useful?