Week 16: Bridging Signals & Observables

The final week of Phase 4 closes the loop between the two reactivity models you've been using in parallel since Week 5. You'll stop treating "convert to a signal" as an afterthought and start designing components signal-first from the beginning.

Phase 4 of 7 Week 16 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Move data between the signal and observable worlds cleanly
  • Load async data with resource() instead of hand-rolled loading state
  • Recognize and avoid the most common signal/RxJS interop bugs

1. toSignal() & toObservable()

You've used toSignal() since Week 7 without a full explanation — here it is. It subscribes to an Observable on your behalf and exposes the latest emitted value as a signal, unsubscribing automatically when the injection context is destroyed.

to-signal.ts
import { Component, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';

export class ProductDetail {
  private route = inject(ActivatedRoute);

  productId = toSignal(
    this.route.paramMap.pipe(map((params) => params.get('id'))),
    { initialValue: null }
  );
}

toObservable() runs the other direction — it turns a signal into an Observable, useful whenever you need to feed a signal's changing value into an RxJS pipeline (debouncing a signal-backed search term before an HTTP call, for instance):

to-observable.ts
import { Component, signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

export class LiveSearch {
  searchTerm = signal('');

  results$ = toObservable(this.searchTerm).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.search(term))
  );

  private search(term: string) {
    return this.http.get<Result[]>(`/api/search?q=${term}`);
  }
}

toObservable() is synchronous-to-async by nature: because signal reads are synchronous, it needs at least one change-detection cycle to notice the signal changed — it uses an effect internally to bridge the two worlds, which is worth knowing if timing ever looks slightly delayed compared to reading the signal directly.

2. resource() for Async Data Loading

resource() is a signal-first API for loading async data — the modern alternative to manually managing loading/error/data signals around an HTTP call. You give it a request (any signal-derived value) and a loader function; it re-runs the loader automatically whenever the request changes.

user-profile.ts
import { Component, signal, resource } from '@angular/core';

export class UserProfile {
  userId = signal(1);

  user = resource({
    request: () => ({ id: this.userId() }),
    loader: async ({ request, abortSignal }) => {
      const res = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
      if (!res.ok) throw new Error('Failed to load user');
      return res.json();
    },
  });
}
user-profile.html
@if (user.isLoading()) {
  <app-spinner />
} @else if (user.error()) {
  <p>Couldn't load this user.</p>
} @else if (user.value(); as u) {
  <h1>{{ u.name }}</h1>
}

Change userId, and resource() automatically cancels any in-flight load (via the abortSignal it hands your loader) and starts a new one — the same cancellation behavior switchMap gave you in Week 15, without writing any RxJS at all.

3. rxResource() — the Observable-Based Variant

Most of your data layer (Week 14) already returns Observables from HttpClientrxResource() is resource()'s sibling for exactly that case, taking a loader that returns an Observable instead of a Promise.

product-detail.ts
import { Component, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { ProductsService } from './products.service';

export class ProductDetail {
  private products = inject(ProductsService);
  productId = signal('42');

  product = rxResource({
    request: () => ({ id: this.productId() }),
    loader: ({ request }) => this.products.getById(request.id),
  });
}

Same .value(), .isLoading(), .error() API as resource() — the only difference is what kind of function you hand the loader. In practice, reach for rxResource() whenever your data layer already speaks Observables (which, after Week 14, is most of your app), and resource() for anything Promise-based, like a raw fetch() call or a non-Angular SDK.

4. Patterns for "Signal-First" Component Design

With resource()/rxResource() in hand, a component's entire data flow can be expressed as signals end to end — inputs, derived state, and now async data too — with RxJS pushed down into services as an implementation detail rather than something every component juggles directly.

signal-first shape
export class OrderHistory {
  // Signal in: route param, already bridged by withComponentInputBinding (Week 10)
  userId = input.required<string>();

  // Signal derived: request shape for the resource
  private request = computed(() => ({ userId: this.userId(), page: this.page() }));
  page = signal(1);

  // Signal out: async data, loading, and error all from one resource
  orders = rxResource({
    request: this.request,
    loader: ({ request }) => this.ordersService.getPage(request.userId, request.page),
  });

  // Signal derived: from the resource's own value
  hasOrders = computed(() => (this.orders.value()?.length ?? 0) > 0);
}

Nothing here is a Promise, a Subject, or a manual subscription — the whole component is readable top to bottom as "signals in, signals out," which is exactly the design goal this pattern is named for.

5. Handling Loading/Error/Success States with resource()

A resource() exposes more than just .value() — its .status() signal gives you the exact current state, useful when isLoading()/error() alone don't capture enough nuance (like distinguishing a fresh load from a reload of already-visible data).

resource status values
// resource().status() is one of:
// 'idle'      -- no request yet (request() returned undefined)
// 'loading'   -- first load in progress, no value yet
// 'reloading' -- request changed, previous value still available while the new one loads
// 'resolved'  -- loader succeeded, value() is populated
// 'error'     -- loader threw, error() is populated
// 'local'     -- value was set manually via resource.set(), not from the loader
distinguishing loading from reloading
@if (orders.status() === 'loading') {
  <app-spinner />
} @else {
  @if (orders.status() === 'reloading') {
    <div class="stale-banner">Refreshing…</div>
  }
  @for (order of orders.value(); track order.id) {
    <app-order-row [order]="order" />
  }
}

This is the pattern that makes pagination feel smooth — the previous page's data stays on screen (rather than flashing to a blank loading state) while reloading fetches the next page underneath it.

6. Avoiding Common Interop Bugs

Two mistakes account for most signal/RxJS interop bugs in practice.

stale closure — reading a signal's value, not the signal
// WRONG: captures the value of count() at effect-creation time, never updates
const count = signal(0);
const currentCount = count(); // reads it ONCE, right now

effect(() => {
  console.log(currentCount); // always logs the original value, forever
});

// RIGHT: read the signal INSIDE the effect, so it's tracked
effect(() => {
  console.log(count()); // re-runs every time count() actually changes
});

The fix is always the same: call the signal inside the reactive context (effect, computed, a template) rather than storing its current value in a plain variable beforehand.

double-subscribing — the async pipe plus a manual subscribe
// WRONG: subscribes twice, so the HTTP request fires TWICE
export class ProductList {
  products$ = this.http.get<Product[]>('/api/products');

  ngOnInit() {
    this.products$.subscribe((products) => console.log(products.length)); // subscription #1
  }
}
product-list.html — template also subscribes
<!-- The async pipe here is a SECOND, separate subscription -->
@for (product of products$ | async; track product.id) {
  <app-product-card [product]="product" />
}

HttpClient's Observables are cold — every subscription triggers its own independent HTTP request. Mixing a manual subscription with the async pipe (or toSignal()) on the same Observable is a common way to accidentally send a request twice. Pick one subscription mechanism — ideally toSignal() or the async pipe — and read the resulting value everywhere else.

7. Hands-on Exercise

Hands-on

Rebuild the Week 15 typeahead using resource() and rxResource()

Two versions of the same feature, so you can compare the approaches directly.

Requirements:

  1. Version A: rebuild the typeahead from Week 15 using rxResource(), with the search term as a signal driving the request, and your existing HttpClient-based search method as the loader.
  2. Version B: rebuild it again using plain resource() with a fetch()-based loader instead.
  3. Both versions need the debounce behavior from Week 15 — since resource() doesn't debounce on its own, derive a debounced signal via toObservable(searchTerm).pipe(debounceTime(300)) piped back through toSignal(), and use that signal as the resource's request.
  4. Show a distinct "searching…" state (via .status()) and a "no results" empty state, for both versions.
  5. Write a short comparison (a code comment block is fine) of what each version needed that the other didn't — cancellation behavior, error handling, and how much RxJS each one actually required.
Hint

The "signal → debounced observable → signal again" round-trip in Step 3 feels redundant the first time you write it — that's expected. It's the honest answer to "how do I debounce a signal," since debouncing is fundamentally a time-based operation RxJS already solves well.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What automatically happens to an in-flight resource() load when its request signal changes again before the first load finishes?

It's cancelled — resource() signals the abort via the abortSignal passed into your loader, and starts a fresh load for the new request value. This gives you the same "only the latest result matters" behavior as switchMap, without writing any RxJS.

Q2

When should you reach for rxResource() instead of plain resource()?

Whenever your loader's data source already returns an Observable — almost always the case for anything going through HttpClient after Week 14. rxResource()'s loader returns an Observable directly; plain resource() expects a Promise, which would mean manually converting an Observable with something like firstValueFrom() for no real benefit.

Q3

What's the practical difference between a resource's 'loading' and 'reloading' status?

'loading' is the very first load — there's no previous value to show yet. 'reloading' means the request changed and a new load is in progress, but the previous successful value is still available in .value() while it loads — letting you show stale-but-present data instead of a jarring blank loading state, which is exactly what makes pagination feel smooth.

Q4

Why does subscribing to the same HttpClient Observable both manually and via the async pipe result in two separate HTTP requests?

HttpClient's Observables are cold — no request is sent until something subscribes, and critically, each subscription runs its own independent execution of the request from scratch. Two separate subscriptions to the same Observable definition means two separate HTTP calls, not one shared result — the fix is to subscribe exactly once (via toSignal() or a single async pipe usage) and read that one result everywhere it's needed.

← Back to Week 15: RxJS Deep Dive Up next Week 17: State Management at Scale →