skip to content

In Angular, what does the equal option on signal() and computed() control, and when is a custom equality function worth it?

level: middleimportance: should knowfreq 34%

answer

  1. decides what counts as a change
  2. Object.is unless you say otherwise
  3. equal means keep the old value
  4. stops churn downstream of a computed
  5. a lax check hides real edits

basics

~20 s

The equal option replaces the default Object.is check that decides whether a new value counts as a change. When it returns true, the old value is kept and dependents are not notified, which suits derivations that rebuild equivalent objects.

solid answer

~40 s

Both `signal()` and `computed()` accept `{equal: (a, b) => boolean}`. The default is `Object.is`. On a writable signal, `set()` or `update()` calls it before storing: if it returns true, the write is dropped and the old reference stays. On a `computed()`, it runs after a recomputation: if the new result equals the old one, the computed keeps the previous value and reports no change, so downstream computeds and templates skip their work. A custom check pays off when a derivation builds a fresh object or array every run, such as a `{count, total}` summary, or when a signal receives structurally identical payloads. It costs a comparison on every write or recompute, and a check that is too lax, such as comparing line items by id only, silently swallows real edits.

code

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

interface LineItem {
  sku: string;
  unitPrice: number;
  qty: number;
}

const items = signal<readonly LineItem[]>([]);

// Too lax: a quantity change for the same SKUs is dropped.
const skusOnly = signal<readonly LineItem[]>([], {
  equal: (a, b) => a.length === b.length && a.every((x, i) => x.sku === b[i].sku),
});

// Cuts propagation: consumers of summary skip work when count and total are unchanged.
const summary = computed(
  () => {
    const list = items();
    return {
      count: list.reduce((n, i) => n + i.qty, 0),
      total: list.reduce((s, i) => s + i.unitPrice * i.qty, 0),
    };
  },
  {equal: (a, b) => a.count === b.count && a.total === b.total},
);

go deeper

for a junior

Recall that signals use Object.is by default and that an equal option can change what counts as a change.

for a middle

Explain where equal runs for signal() and computed(), that a true result keeps the old value, and how that cuts downstream recomputation.

for a senior

Show when a field-level equal on a computed pays off, how an over-lax check drops real edits, and how you test equality functions.

for a principal

Weigh default reference equality plus immutable updates against custom equality as a team convention, including the performance and correctness review burden.

## What the option is In Angular 22.2, `signal()` and `computed()` both take an options object whose `equal` property is a **`ValueEqualityFn<T>`**, a function `(a: T, b: T) => boolean`. It answers one question: *is the new value the same as the old one for the purposes of change notification?* If you omit it, Angular uses **`Object.is`**: - primitives compare by value, with the `Object.is` quirks (`NaN` equals `NaN`; `+0` and `-0` differ); - objects and arrays compare by **reference**. ## Where Angular calls it | Signal kind | When `equal` runs | If it returns `true` | If it returns `false` | |---|---|---|---| | `signal()` | inside `set()` / `update()`, before storing | the write is dropped; the old reference stays; nobody is notified | the value is stored and consumers are notified | | `computed()` | after the derivation re-runs | the computed keeps its **previous** value and its version does not change | the new value is stored and its version bumps | Two details are easy to miss: 1. A computed's `equal` is **not** called on its first run; there is no previous value to compare with. 2. Inside a computed's `equal`, signal reads are **not tracked**, so reading a signal there does not add a dependency. ## Why the computed case matters most A derivation that returns a new object every time is always "changed" under `Object.is`, even when every field is identical. Every consumer downstream then does its own work again. Consider a cart badge fed by a summary: ```ts readonly summary = computed( () => ({ count: this.items().reduce((n, i) => n + i.qty, 0), total: this.items().reduce((s, i) => s + i.unitPrice * i.qty, 0), }), {equal: (a, b) => a.count === b.count && a.total === b.total}, ); ``` If a line's description changes but count and total do not, `summary` recomputes, finds the result equal, keeps the old object and reports no change. Any `computed()` or template that reads `summary()` sees the same version and skips its own work. A custom `equal` on a computed is therefore a way to **cut propagation** at a boundary where the value is structurally stable. ## Good reasons to write one - A `computed()` builds a **fresh object or array** each run, and downstream work is expensive or visible. - A writable signal is fed from a source that re-sends **identical payloads**, such as polling a server, and you want equal payloads to be no-ops. - The value has a natural **identity or version field** that fully determines its content. ## Costs and traps - **Every write or recompute pays for the comparison.** A deep equality over a large list on every keystroke can cost more than the work it saves. Prefer comparing a few fields, or a version number, over a generic deep compare. - **A lax check hides real changes.** `equal: (a, b) => a.sku === b.sku` on a line-item signal means a quantity change is dropped entirely: the old value stays, nothing notifies, and the bug looks like "the setter doesn't work". - **It does not rescue in-place mutation.** After `push()` the old and new value are the same array, so any reasonable function returns `true`. - **`() => false` is not a fix.** It makes every write notify, including genuine no-ops, and throws away the short-circuit the graph relies on. - **Keep it pure.** The function should compare, not log or write state; it can run often and at points you do not control. - A third-party deep-equality helper is fine, as the Angular guide shows with a library `isEqual`, but it is a dependency choice, not an Angular feature. ## A decision rule 1. Start with the default. Immutable updates plus `Object.is` are correct and cheap. 2. Add `equal` to a **computed** when profiling or reasoning shows equivalent results re-triggering expensive consumers. 3. Add `equal` to a **writable signal** when its producer routinely sends equal values. 4. Make the comparison **cover every field consumers use**, and test the case where only one field changes.

  • In Angular, when a writable signal's equal returns true, which reference does the signal hold afterwards?
    The old one. The setter returns early without storing the candidate, so later reads return the previous object, not the equivalent new one. That matters if code later compares references or relies on fields the equality function ignored.
  • In Angular, does a signal read inside a computed's equal function become a dependency?
    No. Angular runs a computed's equality check with no active consumer, so reads inside it are not tracked. The function should compare its two arguments and nothing else; reading other state there makes the result depend on something the graph cannot see.

A custom equal function is a doorman with a guest list: whoever he waves through as 'already inside' never reaches the party, so a doorman who checks only surnames will turn away a guest who changed their outfit but still needed to be seen.

saying these in an interview costs you the question

  • Signals compare values deeply by default, so equal is rarely needed.
  • The equal option only exists on writable signals, not on computed().
  • equal: () => false is a clean way to make mutated arrays notify.
  • When equal returns true, the new value is stored but not announced.
  • Comparing items by id alone is a safe, cheap equality function.