skip to content

In current Angular, how would you rewrite a BehaviorSubject-based data service with signals, and what changes for the components that consume it?

level: middleimportance: should knowfreq 55%

answer

  1. private writable, public readonly
  2. derived values without pipe()
  3. equality decides notification
  4. no subscription to tear down
  5. async work still needs a source

basics

~10 s

Hold state in a private signal(), expose it with asReadonly(), derive values with computed(), and write through set()/update() with new references; consumers read service.flags() directly, with no subscription, async pipe or teardown.

solid answer

~40 s

The `BehaviorSubject` becomes `private readonly _flags = signal<Flags>({})`, the `asObservable()` stream becomes `readonly flags = this._flags.asReadonly()`, and derived streams built with `map` become `computed()` signals. Methods call `this._flags.update(f => ({ ...f, [name]: on }))`. Components read `flags()` in templates or code: there is no `async` pipe, no `null` before the first value, and nothing to unsubscribe. A template that reads a signal is tracked, so a change marks that view for refresh even under `OnPush`, the default since v22. Two things carry over: updates must produce new references, because signals compare with `Object.is` and ignore a `set()` with the same object; and asynchronous work (HTTP, polling, debouncing) still needs RxJS or a resource API feeding the signal.

code

ts · 15 lines
ts
import { Component, inject } from '@angular/core';
import { FeatureFlagService } from './feature-flag.service';

@Component({
  selector: 'app-header',
  template: `
    @if (flags.betaOn()) {
      <span class="badge">beta</span>
    }
    <button (click)="flags.setFlag('beta', !flags.betaOn())">toggle beta</button>
  `,
})
export class Header {
  protected readonly flags = inject(FeatureFlagService);
}

go deeper

for a junior

Know the mapping: private signal, asReadonly() for consumers, computed() for derived values, and that components read the signal by calling it.

for a middle

Explain Object.is equality and why updates must create new references, and why no subscription or async pipe is needed.

for a senior

Decide what stays in RxJS (HTTP, polling, cancellation), how it feeds the signal, and how OnPush-by-default and zoneless rely on signal reads for refresh.

for a principal

Plan a gradual migration of observable services to signals, keeping interop at the edges so teams are not forced into a big-bang rewrite.

## From subject to signal, member by member A signal-based data service keeps the same contract as the observable version: **one private writer, read-only consumers, immutable updates**. Only the primitives change. | Observable service | Signal service | |---|---| | `private state = new BehaviorSubject<Flags>({})` | `private readonly _flags = signal<Flags>({})` | | `flags$ = this.state.asObservable()` | `flags = this._flags.asReadonly()` | | `isOn$ = this.flags$.pipe(map(...))` | `betaOn = computed(() => this._flags()['beta'] ?? false)` | | `this.state.next({...})` | `this._flags.set({...})` or `update(f => ...)` | | `this.state.getValue()` | `this._flags()` | ```ts import { Injectable, computed, signal } from '@angular/core'; export interface Flags { [name: string]: boolean } @Injectable({ providedIn: 'root' }) export class FeatureFlagService { private readonly _flags = signal<Flags>({}); readonly flags = this._flags.asReadonly(); readonly betaOn = computed(() => this._flags()['beta'] ?? false); setFlag(name: string, on: boolean): void { this._flags.update(f => ({ ...f, [name]: on })); } } ``` `asReadonly()` is a method of `WritableSignal` that returns a `Signal` with the same value but no `set()` or `update()`. It plays the role `asObservable()` played. ## What changes for consumers - **Reading is synchronous.** `this.flags.betaOn()` returns a value right now. There is no "before the first emission" state, so no `null` handling in templates. - **No subscription, no teardown.** The component does not subscribe, so there is no `takeUntilDestroyed()` and no `async` pipe. - **Change detection follows reads.** When a template reads a signal, Angular records it as a dependency of that view. When the signal changes, Angular marks the view for refresh, including `OnPush` views. Components are `OnPush` by default since v22 and zoneless is the default since v21, so signal reads are one of the main notifications that keep such views current. - **Derived state is lazy and memoized.** A `computed()` recalculates only when a signal it read has changed and someone reads it again. ## The equality trap Signals decide whether to notify by comparing the old and new value; the default comparison is `Object.is`. That has two consequences: 1. `this._flags().beta = true` mutates the object and notifies nobody. 2. `this._flags.set(this._flags())` after a mutation is ignored, because it is the same reference. So the immutable-update habit from the `BehaviorSubject` version is now **required** rather than merely good practice. A custom `equal` function can be passed to `signal()`, but new references are the simpler rule. ## What signals do not replace Signals hold **values**; RxJS models **events over time**. A signal service still needs something to handle: - HTTP requests and their cancellation, - polling, debouncing, retrying, - combining streams where timing matters. Common options: 1. Subscribe in the service and write results into the signal: `http.get(...).subscribe(f => this._flags.set(f))`. Simple for a one-shot load in a root service. 2. Convert an Observable with `toSignal()`, or expose a signal to RxJS code with `toObservable()`. 3. Use the resource APIs (`resource()`, `rxResource()`, `httpResource()`), which model loading, value and error as signals. ## When to keep the observable version - The codebase is RxJS-heavy and consumers compose the stream with operators. - The state is really a stream of events (notifications, socket messages) rather than a current value. - Migration cost is high and the existing service works; signals and observables interoperate, so the two styles can coexist. For new state that is "the current value of X", a signal service is the idiomatic choice in current Angular. ## Testing the signal version Tests get simpler as well. There is no need for `fakeAsync`, marble diagrams or subscribing with a spy just to read state: 1. Get the service from `TestBed.inject(FeatureFlagService)` or construct it directly if it has no dependencies. 2. Call `setFlag('beta', true)`. 3. Assert `expect(service.betaOn()).toBe(true)`. Because `computed()` is evaluated on read, the assertion sees the derived value immediately. Component tests that render the template still need a change-detection pass (for example `fixture.detectChanges()` or awaiting `fixture.whenStable()`) before the DOM reflects the new value, since the signal change schedules the view refresh rather than performing it synchronously.

  • How would the signal service load its initial flags from the server?
    Inject `HttpClient` and write the response into the private signal, for example `http.get<Flags>('/api/flags').subscribe(f => this._flags.set(f))` in the constructor of a root service, since the one-shot request completes on its own. For loading and error state as signals, a resource API such as `httpResource()` models them directly.
  • Does a component reading a signal service need to call markForCheck()?
    No. A template that reads a signal registers it as a dependency of that view, and a change to the signal marks the view for refresh, including under `OnPush`. `markForCheck()` is for plain, non-signal fields changed outside a template event or input update, for example inside a manual subscription.

saying these in an interview costs you the question

  • Mutating the object inside a signal notifies its readers
  • Setting a signal to the same object after mutating it forces an update
  • OnPush components need markForCheck() to see signal changes
  • Signals replace RxJS entirely, including HTTP cancellation and polling
  • Consumers must unsubscribe from service signals in ngOnDestroy