skip to content

In RxJS, why does distinctUntilChanged() emit every sensor-reading object, and how do a comparator or distinctUntilKeyChanged() fix it?

level: middleimportance: should knowfreq 47%

answer

  1. new object each time
  2. === compares references
  3. comparator returns true means same
  4. key selector or key name

basics

~20 s

Its default === compares object references, and each reading is a new object, so none look equal. Pass a comparator returning true for equal readings, a key selector as the second argument, or use distinctUntilKeyChanged('celsius') for one top-level property.

solid answer

~40 s

`distinctUntilChanged()` compares the new value with the last emitted one using `===`. A sensor service that builds `{ celsius, at }` per reading produces a fresh reference every time, so every object passes. Three fixes: a comparator, `distinctUntilChanged((prev, curr) => prev.celsius === curr.celsius)`, where returning `true` means *equal, drop it*; a key selector as the second argument, `distinctUntilChanged((a, b) => a === b, r => r.celsius)`, which stores only the selected key; or `distinctUntilKeyChanged('celsius')`, shorthand for comparing one top-level property, with an optional compare function for that property. The stored reference is always the last *emitted* value, so a tolerance comparator still catches slow drift.

code

ts · 13 lines
ts
import { Observable, distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs';

interface Reading { celsius: number; at: number; }

declare const readings$: Observable<Reading>;

// Drop readings whose temperature did not change
const changed$ = readings$.pipe(distinctUntilKeyChanged('celsius'));

// Drop readings whose value, rounded to the nearest half degree, is unchanged
const significant$ = readings$.pipe(
  distinctUntilChanged((a, b) => a === b, r => Math.round(r.celsius * 2) / 2)
);

go deeper

for a junior

Recall that === compares object references, so fresh objects never look equal, and name distinctUntilKeyChanged as the one-property fix.

for a middle

Explain the comparator's true-means-equal contract, the argument order with a key selector, and that only emitted values become the reference.

for a senior

Diagnose the mutation trap and the always-different timestamp field, and choose between a comparator, a key selector and mapping to a primitive.

for a principal

Argue for immutable emissions across the codebase so that change detection, signals and de-duplication all rely on the same reference contract.

## Why objects defeat the default `distinctUntilChanged` decides whether a value is a repeat by calling a comparator with the **previous key** and the **current key**. With no arguments, the key is the value itself and the comparator is `===`. For primitives that is value equality. For objects it is **reference equality**: two objects with identical fields are still different references. A typical sensor feed maps each raw frame to a new object: ```ts const readings$ = frames$.pipe(map(f => ({ celsius: f.c, at: f.t }))); ``` Every emission is a brand-new object, so `readings$.pipe(distinctUntilChanged())` lets **everything** through. The operator is working correctly; the equality it was given is simply not the one you meant. ## Three ways to say what "the same" means | approach | call | what is stored as the previous key | |---|---|---| | comparator | `distinctUntilChanged((p, c) => p.celsius === c.celsius)` | the whole previous object | | key selector | `distinctUntilChanged((a, b) => a === b, r => r.celsius)` | the selected number | | property shorthand | `distinctUntilKeyChanged('celsius')` | the whole previous object | | property + compare | `distinctUntilKeyChanged('celsius', (x, y) => Math.abs(x - y) < 0.5)` | the whole previous object | The rules that matter: - The comparator returns **`true` when the two are equal** — that value is dropped. Returning `true` for "different" inverts the operator and is the most common bug. - In RxJS 7.8 the signature is `distinctUntilChanged(comparator?, keySelector?)`: the **comparator comes first**. The key selector is the second argument and the comparator then receives keys, not values. - `distinctUntilKeyChanged(key, compare?)` takes a **top-level property name** (`keyof T`), not a dotted path. Internally it is a `distinctUntilChanged` comparator that reads `x[key]` and `y[key]`. A simpler fourth option is to `map` to the primitive before de-duplicating, when downstream only needs the number and not the reading object. ## Tolerance and drift A sensor jitters. A tolerance comparator such as `(a, b) => Math.abs(a - b) < 0.5` drops readings within half a degree. The detail that makes it safe: the operator updates its stored key **only when it emits**. Suppressed values never become the reference. 1. `20.0` — first value, emitted, stored. 2. `20.3` — within 0.5 of `20.0`, dropped; the reference stays `20.0`. 3. `20.6` — 0.6 away from `20.0`, emitted, stored. 4. `20.9` — within 0.5 of `20.6`, dropped. If the reference moved on every suppressed value, a slow drift of 0.3 per step could climb forever without a single emission. Because it compares with the last *emitted* value, drift beyond the tolerance always surfaces. ## The mutation trap The opposite failure happens when a producer **mutates one object and re-emits it**. Previous and current are then the same reference: - with the default `===`, every update after the first is dropped; - with a field comparator, `prev.celsius === curr.celsius` compares the object with itself and is always `true`, so again everything is dropped; - with a **key selector** returning a primitive, the stored key is a snapshot taken at emission time, so changes are still detected. The real fix is immutable emissions: produce a new object per change. That also keeps Angular `OnPush` components and signal-based consumers honest, because they rely on reference changes too. ## Choosing between the fixes | situation | best fit | |---|---| | downstream needs only the number | `map(r => r.celsius)` then `distinctUntilChanged()` | | downstream needs the object, one field defines change | `distinctUntilKeyChanged('celsius')` | | several fields define change | a comparator over those fields | | change means "moved more than a tolerance" | a tolerance comparator, or a key selector that rounds | | the producer may mutate in place | a key selector returning a primitive, until the producer is fixed | ## Cost and correctness checklist - Keep comparators **pure and cheap** — they run for every value. A deep equality via `JSON.stringify` works but costs a serialisation per reading and depends on property order. - Compare the fields that define "changed" for the consumer, not every field: a timestamp that always differs makes every reading distinct. - Test the comparator with a repeated reading, a changed reading and a slow drift.

  • Why does a tolerance comparator not hide a slow drift of 0.3 degrees per reading?
    `distinctUntilChanged` replaces its stored key only when it emits. Suppressed readings never become the reference, so each new reading is compared with the last emitted one; once the accumulated drift exceeds the tolerance, the reading is emitted.
  • A service mutates one reading object and re-emits it; what does distinctUntilKeyChanged('celsius') do?
    It drops every update after the first. Previous and current are the same object, so comparing `x.celsius` with `y.celsius` compares the object with itself and is always equal. Emit a new object per change, or use a key selector returning a primitive snapshot.

saying these in an interview costs you the question

  • distinctUntilChanged() compares objects field by field by default.
  • The comparator should return true when the two values are different.
  • distinctUntilKeyChanged accepts a dotted path such as 'reading.celsius'.
  • The key selector is the first argument of distinctUntilChanged.
  • Suppressed values become the new reference, so slow drift is never reported.