Skip to the solution
HardFrontend10 min read

Explain how you can bridge Signals and RxJS observables using toSignal() and toObservable().

100 views

The problem

Question presented to candidate: "Walk me through building a user-detail component in Angular 22 where the user id is a signal and the data comes from HttpClient. Use toObservable() and toSignal(), and tell me which toSignal options you would set and why."

What a strong answer should cover:

  • The shape: toObservable(userId).pipe(switchMap(id => http.get(...))) then toSignal(...), created in a field initialiser so it has an injection context and is cleaned up with the component.
  • initialValue versus requireSync: the first gives a value (and type) before the first emission; the second asserts a synchronous first emission and throws NG0601 if there is none. In v22 the initial value must match the Observable's type, or be null/undefined.
  • Errors: an error in the source makes the signal throw on every read and ends the subscription, so catchError belongs inside the switchMap, mapping errors to a view state.
  • injector to create either one outside an injection context, manualCleanup to outlive the context, equal and debugName.
  • Loading state with startWith, cancellation of stale requests by switchMap, and the v22 alternative: httpResource() / rxResource().

Clarifying questions expected:

  • "Should the previous user stay on screen while the next one loads, or show a loading state?"
  • "What should a 404 look like, versus a network failure?"

Code / implementation expected: Yes β€” the signal to HTTP to signal pipeline, with a loading, success and error view state.

  • signals
  • rxjs

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read Β· 10 sections

Read the code

AngulartoSignal options lab: initialValue, requireSync, NG0601 and errors
import { Component, Injector, inject, signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, Subject, catchError, of } from "rxjs";

const failingHttp = () => new Observable<string>((sub) => sub.error(new Error("HTTP 500")));

@Component({
  selector: "app-root",
  template: `
    <p>no initialValue: {{ plain() ?? "undefined" }}</p>
    <p>initialValue -1: {{ withInitial() }}</p>
    <p>requireSync on BehaviorSubject: {{ fromBehavior() }}</p>
    <p>error caught in the pipe: {{ safe() }}</p>
    <button (click)="push()">subject.next(7)</button>
    <button (click)="tryRequireSync()">requireSync on a Subject</button>
    <button (click)="readFailing()">read a failed toSignal</button>
    <p>{{ message() }}</p>
  `,
})
export class AppComponent {
  private injector = inject(Injector);
  private subject = new Subject<number>();

  plain = toSignal(this.subject);                                   // Signal<number | undefined>
  withInitial = toSignal(this.subject, { initialValue: -1 });       // Signal<number>
  fromBehavior = toSignal(new BehaviorSubject("ready"), { requireSync: true }); // Signal<string>, no undefined
  failing = toSignal(failingHttp(), { initialValue: "n/a" });
  safe = toSignal(failingHttp().pipe(catchError(() => of("fallback"))), { initialValue: "" });
  message = signal("");

  push() { this.subject.next(7); }

  tryRequireSync() {
    try {
      // Outside the constructor, so the injector is passed explicitly.
      toSignal(new Subject<number>(), { requireSync: true, injector: this.injector });
    } catch (e: any) { this.message.set(e.message); }
  }

  readFailing() {
    try { this.failing(); } catch (e: any) { this.message.set(`reading threw: ${e.message}`); }
  }
}
AngularuserId signal to HttpClient with switchMap and back to a view-state signal
import { ApplicationConfig, Component, Injectable, computed, inject, signal } from "@angular/core";
import { HttpClient, HttpErrorResponse, HttpInterceptorFn, HttpResponse, provideHttpClient, withInterceptors } from "@angular/common/http";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { catchError, delay, map, of, startWith, switchMap, throwError, timer } from "rxjs";

type User = { id: number; name: string };
type View = { state: "loading" } | { state: "ok"; user: User } | { state: "error"; message: string };

@Injectable({ providedIn: "root" })
export class UserApi {
  private http = inject(HttpClient);
  get(id: number) { return this.http.get<User>(`/api/users/${id}`); }
}

@Component({
  selector: "app-root",
  template: `
    <button (click)="userId.update(id => id + 1)">Next user</button>
    @switch (view().state) {
      @case ("loading") { <p>Loading user {{ userId() }}...</p> }
      @case ("ok") { <p>{{ name() }}</p> }
      @case ("error") { <p>Could not load user {{ userId() }}</p> }
    }
  `,
})
export class AppComponent {
  private api = inject(UserApi);
  userId = signal(1);

  // signal -> observable -> HTTP -> observable -> signal, all in the field initialiser (an injection context)
  view = toSignal(
    toObservable(this.userId).pipe(
      switchMap((id) =>
        this.api.get(id).pipe(
          map((user): View => ({ state: "ok", user })),
          catchError((e) => of<View>({ state: "error", message: e.message })), // keep the outer stream alive
          startWith<View>({ state: "loading" }),
        ),
      ),
    ),
    { initialValue: { state: "loading" } as View },
  );
  name = computed(() => { const v = this.view(); return v.state === "ok" ? v.user.name : ""; });
}

// Fake backend for the demo: users 1 and 2 exist, anything else is a 404.
const fakeBackend: HttpInterceptorFn = (req) => {
  const id = Number(req.url.split("/").pop());
  const users: Record<number, string> = { 1: "Ada Lovelace", 2: "Grace Hopper" };
  if (!users[id]) return timer(20).pipe(switchMap(() => throwError(() => new HttpErrorResponse({ status: 404, url: req.url }))));
  return of(new HttpResponse({ status: 200, body: { id, name: users[id] } })).pipe(delay(20));
};
export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([fakeBackend]))],
};

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 120 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextHard

What are the differences between State Management libraries in Angular?

Continue