skip to content

In Angular, when a computed recomputes to an equal value, what happens to the computeds, effects and templates that depend on it?

level: middleimportance: should knowfreq 36%

answer

  1. version stays the same
  2. dependants were already marked
  3. poll before running
  4. effect body skipped
  5. new arrays defeat it

basics

~20 s

Its version is not bumped, so dependants that poll it see no change: downstream computeds keep their cached values, a dirty effect skips its body, and a dirty template consumer does not refresh its view.

solid answer

~50 s

A computed compares each new result with the previous one using its `equal` function, `Object.is` by default. If they are equal it keeps the old value and does **not** increment its `version`. Its dependants were already marked dirty during the push, because the push runs before anything recomputes. But before any of them does work, it polls its producers' versions. A downstream computed sees the same version and marks itself clean without running; an effect that was scheduled finds no changed producer and skips its body; a component whose template consumer was marked dirty is visited during change detection, finds nothing changed and is not re-rendered. The short-circuit only helps when equality holds: a computed that returns a new array or object on every run, such as the result of `filter()`, always counts as changed.

code

ts · 26 lines
ts
import { Component, computed, effect, signal } from '@angular/core';

@Component({
  selector: 'app-name-badge',
  template: `
    @if (nameIsLong()) {
      <span class="warning">Name will be truncated</span>
    }
  `,
})
export class NameBadge {
  readonly firstName = signal('Ada');
  readonly lastName = signal('Lovelace');
  readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
  readonly nameIsLong = computed(() => this.fullName().length > 20);

  constructor() {
    effect(() => console.log('layout flag:', this.nameIsLong()));
  }

  rename(): void {
    // fullName changes, nameIsLong stays false:
    // the effect body is skipped and this view is not re-rendered.
    this.firstName.set('Adb');
  }
}

go deeper

for a junior

Recall that a computed which produces the same value as before does not make the things that read it update.

for a middle

Explain versions, why the push marks consumers before the value is known, and how polling lets effects and templates skip work.

for a senior

Use narrow computeds that return primitives to cut propagation, and diagnose effects or views that did or did not rerun after a write.

for a principal

Set conventions for derivation granularity and custom equality so large signal graphs stay cheap without over-engineering comparisons.

## The situation Take the name example: `fullName` is computed from `firstName` and `lastName`, and a second computed flags long names for a badge layout: ```ts const nameIsLong = computed(() => fullName().length > 20); ``` An effect logs when the layout flips, and the template shows a warning icon when `nameIsLong()` is true. Now the user edits their first name from `Ada` to `Adb`. `fullName` changes, but `nameIsLong` stays `false`. What runs? This answer assumes Angular 22. ## Two facts that combine 1. **The push marks everything first.** `firstName.set('Adb')` marks every live consumer downstream dirty — `fullName`, `nameIsLong`, the effect and the template consumer — before anything recomputes. At that moment Angular cannot know that `nameIsLong` will come out the same. 2. **Every consumer polls before it works.** A computed, an effect and a template consumer each check their producers' `version` numbers before doing anything, recursively bringing stale computeds up to date. The result is that the dirty marks were pessimistic, and the polls correct them. ## Walking through it 1. The scheduled effect is about to run. It polls `nameIsLong`, which is stale, so `nameIsLong` polls `fullName`. 2. `fullName`'s producer `firstName` has a new version, so `fullName` recomputes to `'Adb Lovelace'` — a new string — and bumps its version. 3. `nameIsLong` recomputes: `'Adb Lovelace'.length > 20` is `false`, equal to the old `false`. It keeps the old value and **does not bump its version**. 4. The effect compares `nameIsLong`'s version with the one it recorded: identical. It marks itself clean and **skips its body**. 5. During change detection the component's template consumer is dirty, so Angular polls it. If the template reads only `nameIsLong`, polling finds no changed version and the view **is not refreshed**. If it also reads `fullName`, that version did change, and the view refreshes. | Consumer | Marked dirty by the write? | Does work? | | --- | --- | --- | | `fullName` | Yes | Recomputes; value changed, version bumped | | `nameIsLong` | Yes | Recomputes; value equal, version kept | | Effect reading `nameIsLong` | Yes | No: body skipped after polling | | Template reading only `nameIsLong` | Yes | No: view not refreshed | | A computed reading only `nameIsLong` | Yes | No: stays cached | ## The equality check - The default is `Object.is`, the same check a writable signal applies in `set()`. - A custom `equal` function can be passed to `computed()` when structural comparison makes sense; its reads are not tracked. - If the computation throws, the error is cached and rethrown on each read until a producer changes. ## Where the short-circuit fails you - **New references every time.** `computed(() => orders().filter((o) => o.open))` returns a new array on each run, so `Object.is` never holds and every dependant does its work, even if the contents are the same. - **Objects built for templates.** `computed(() => ({ name: fullName(), long: nameIsLong() }))` has the same problem. - **Too-wide derivations.** One large computed that bundles many fields changes whenever any field changes. Several narrow computeds, each returning a primitive, let the short-circuit cut propagation early. ## Why it matters The short-circuit is what makes narrow computeds a performance tool: a boolean or count derived from a noisy signal acts as a firewall, so effects and views above it do nothing while its answer stays the same. It also explains a debugging surprise: an effect or template that "should" have rerun after a write did not, because an intermediate computed produced an equal value.

  • If the dependants are marked dirty anyway, is there any cost when the value comes out equal?
    Some: the push walks the live edges, the effect is scheduled, change detection visits the marked view and the computeds along the path recompute. What is saved is everything past the equal node — downstream computations, effect bodies and the template refresh.
  • How would you keep a filtered list computed from making every consumer rerun?
    Derive narrower primitives where possible, such as a count or a boolean, for consumers that only need those. If consumers need the list itself, supply a custom `equal` function to `computed()` that compares the contents, accepting that the comparison has its own cost.

saying these in an interview costs you the question

  • An equal recomputed value still bumps the computed's version.
  • Dependants are only marked dirty after the computed has compared values.
  • An effect runs its body whenever it has been marked dirty.
  • A filter() or map() result will short-circuit when the contents match.
  • The template re-renders whenever any signal it depends on is written.