In current Angular, how would you rewrite a BehaviorSubject-based data service with signals, and what changes for the components that consume it?
answer
- private writable, public readonly
- derived values without pipe()
- equality decides notification
- no subscription to tear down
- async work still needs a source
basics
~10 sHold 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 sThe `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 linesimport { 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
Know the mapping: private signal, asReadonly() for consumers, computed() for derived values, and that components read the signal by calling it.
Explain Object.is equality and why updates must create new references, and why no subscription or async pipe is needed.
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.
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