skip to content

You are migrating an Angular cart service from a BehaviorSubject with map() pipes to signal() and computed(); what maps to what, and what behaviour changes?

level: seniorimportance: should knowfreq 45%

answer

  1. next becomes set or update
  2. asObservable becomes asReadonly
  3. derived pipes become computed
  4. equal values no longer emit
  5. no stream of every intermediate value

basics

~20 s

The subject becomes a private signal(), next() becomes set() or update(), asObservable() becomes asReadonly(), and derived map() streams become computed(). Behaviour changes: equal writes stop notifying, derived values are pulled lazily, and intermediate values are not delivered.

solid answer

~40 s

Mechanically the port is direct: `new BehaviorSubject<LineItem[]>([])` becomes a private `signal<LineItem[]>([])`; `next(v)` becomes `set(v)` or `update(fn)`; `getValue()` becomes calling the signal; `asObservable()` becomes `asReadonly()`; `items$.pipe(map(...), distinctUntilChanged(), shareReplay(1))` collapses into a `computed()`, which already dedupes by equality and shares one cached value. The behaviour differences are what bite. `next()` on a subject emits every time, even with a mutated same array; `set()` with the same reference is a no-op, so mutation-based code silently stops updating. A subject pushes every value to every subscriber; a signal holds only the latest, and `computed()` recomputes lazily on read, so three updates in one handler yield one recomputation. Anything that relied on seeing each emission needs an event stream, not a signal.

code

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

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

@Injectable({providedIn: 'root'})
export class CartStore {
  // was: private items$$ = new BehaviorSubject<LineItem[]>([])
  private readonly _items = signal<readonly LineItem[]>([]);
  // was: readonly items$ = this.items$$.asObservable()
  readonly items = this._items.asReadonly();
  // was: items$.pipe(map(...), distinctUntilChanged(), shareReplay(...))
  readonly subtotal = computed(() =>
    this._items().reduce((s, i) => s + i.unitPrice * i.qty, 0),
  );
  readonly count = computed(() => this._items().reduce((n, i) => n + i.qty, 0));

  add(line: LineItem) {
    // was: getValue().push(line); next(list)  -- would be a silent no-op with set()
    this._items.update(list => [...list, line]);
  }

  remove(sku: string) {
    this._items.update(list => list.filter(i => i.sku !== sku));
  }
}

go deeper

for a junior

Recall the direct mapping: private signal for the subject, set or update for next, asReadonly for asObservable, computed for derived pipes.

for a middle

Explain why same-reference writes stop notifying after the port, and why computed() replaces distinctUntilChanged and shareReplay for synchronous derivations.

for a senior

Plan the migration: find mutate-then-next sites, keep per-event and async logic in RxJS, and add tests for equal writes and single-field edits.

for a principal

Decide the store boundary for the team: which state becomes signal-native, which stays stream-based, and how both are bridged and tested consistently.

## The starting point A typical pre-signals cart store looks like this: ```ts private readonly items$$ = new BehaviorSubject<LineItem[]>([]); readonly items$ = this.items$$.asObservable(); readonly subtotal$ = this.items$.pipe( map(list => list.reduce((s, i) => s + i.unitPrice * i.qty, 0)), distinctUntilChanged(), shareReplay({bufferSize: 1, refCount: true}), ); add(line: LineItem) { const list = this.items$$.getValue(); list.push(line); this.items$$.next(list); } ``` Components use `subtotal$ | async`. Moving this to Angular 22.2 signals is a common senior-level task because the API mapping is easy and the behavioural differences are not. ## The API mapping | RxJS construct | Signal equivalent | Note | |---|---|---| | `new BehaviorSubject(initial)` | `signal(initial)` | both always hold a current value | | `subject.next(v)` | `set(v)` or `update(fn)` | `update()` must return a new reference for arrays and objects | | `subject.getValue()` / `.value` | `items()` | a read inside a reactive context is tracked; use `untracked()` if it must not be | | `subject.asObservable()` | `items.asReadonly()` | hides the writer; does not freeze the value | | `pipe(map(...))` | `computed(() => ...)` | lazy and memoized | | `distinctUntilChanged()` | built-in equality | `Object.is` by default, `equal` option for custom rules | | `shareReplay(1)` on derived state | nothing | every reader shares the computed's cached value | | `combineLatest([a$, b$]).pipe(map(...))` | `computed(() => f(a(), b()))` | reads consistent current values | | `x$ \| async` in the template | `x()` in the template | no subscription to manage | Converting at the edges between the two worlds, `toSignal()` and `toObservable()`, is a separate interop topic; here the store itself becomes signal-native. ## Behaviour change 1: equal writes stop notifying `BehaviorSubject.next()` emits on every call, whatever the value. The original `add()` mutates the array and calls `next()` with the **same** reference, and it works. Port it literally to `this.items.set(list)` and it silently stops working: the signal compares old and new with `Object.is`, finds the same array, and drops the write. `subtotal` keeps its cached value and the view does not refresh. The migration must therefore include **making every write immutable**: ```ts add(line: LineItem) { this.items.update(list => [...list, line]); } ``` Search for `getValue()` followed by mutation; those are the call sites that break. ## Behaviour change 2: pull, not push, and no intermediate values A subject **pushes** each value synchronously to every subscriber. A signal **holds** the latest value; a `computed()` recomputes only when read, and only if a dependency changed. - Three `update()` calls in one click handler produce three subject emissions but, for a template reading `subtotal()`, one recomputation at the next read. - Code that **subscribed to observe every change**, such as an audit trail, an undo stack or analytics per edit, cannot be expressed as a `computed()`. Keep an explicit event channel for those, or record the event where the write happens. - Derived values read **consistent** current inputs. A `combineLatest` of two streams derived from one source can emit an intermediate pair; a `computed()` reading two computeds of the same signal does not see that intermediate state. ## Behaviour change 3: no completion, no error channel, no async - A signal never completes and has no `error` notification. If a derivation throws, the `computed()` caches the error and rethrows it on every read until a dependency changes. - `computed()` is synchronous. Anything asynchronous in the old pipe, like `switchMap` to an HTTP call or `debounceTime`, does not belong in a `computed()`; it stays in RxJS or moves to a resource API. ## Behaviour change 4: who is notified and when views refresh With `async`, the pipe marks the view for check on each emission. With signals, a template that reads `subtotal()` is registered as a consumer and refreshed when the value really changes. In a v22 app, where `OnPush` is the default and zoneless the default since v21, this is the path that keeps views current. ## A migration checklist 1. Replace the subject with a private `signal()`; expose `asReadonly()`. 2. Rewrite every write as an immutable `set()` or `update()`; grep for `getValue()` plus mutation. 3. Turn each synchronous `map`/`combineLatest` derivation into a `computed()`; drop `distinctUntilChanged` and `shareReplay` there. 4. Keep asynchronous and event-per-change logic in RxJS, bridging at the edge. 5. Replace `| async` reads with signal calls; remove now-dead subscription handling. 6. Add tests that write the **same** value twice and **one field** of a line, to prove notifications behave as intended.

  • In an Angular signal store, how do you keep an audit log of every cart edit that the BehaviorSubject subscription used to provide?
    Record the event where the write happens, in the store method that calls `update()`, or keep an explicit event stream alongside the signal. A `computed()` only sees the latest state when read, so it cannot reconstruct each intermediate edit.
  • In a migrated Angular cart store, what replaces distinctUntilChanged() and shareReplay() on derived state?
    Nothing extra. A `computed()` compares each result with its equality function, `Object.is` or a custom `equal`, and reports no change for equal results, which covers `distinctUntilChanged()`. It also caches one value that every reader shares, which covers `shareReplay(1)` for synchronous derivations.
  • Which parts of an Angular cart service should stay in RxJS after the migration?
    Asynchronous and time-based logic: HTTP calls with cancellation, debounced search, retries, and streams where every event matters. Signals model current state and synchronous derivations; they have no notion of time, cancellation or completion.

saying these in an interview costs you the question

  • A literal next() to set() swap is safe, including mutate-then-set code.
  • Every update() delivers a separate value to computed() and the template.
  • computed() is a good place for switchMap-style HTTP loading.
  • asReadonly() makes the exposed array immutable like a frozen copy.
  • Signals still need shareReplay() to avoid recomputing for each reader.