DEV Community

Cover image for Reactivity in Data Fetching: httpResource, rxResource vs the HttpClient
Wayne Gakuo
Wayne Gakuo

Posted on

Reactivity in Data Fetching: httpResource, rxResource vs the HttpClient

In my years as a Software Engineer, I have come to appreciate the importance of declarative programming: focus less on syntax and more on functionality. When it comes to data fetching, this is, of course, no different.

For years now, data fetching in Angular had a familiar shape: inject HttpClient, return an Observable, subscribe in the component (or use async pipe), and hand-roll everything around it. However, when it came to loading state, error messages, canceling stale requests when a user changes a filter, resetting state when the ID changes, etc, you had to do a lot of monkey-patching just to get things working right.

Angular's Resource API, which was introduced in v19 and later became stable in v22, was designed with these problems in mind. Instead of orchestrating the request lifecycle yourself, you declare what to fetch from reactive inputs, and the framework gives you value, isLoading, error, and status as signals.

PS: This article is not about pitting RxJS against Signals; the two paradigms are complementary rather than competitive, and this is one approach you can use. With that out of the way, let's continue😄

The HttpClient approach

Here's a pattern many of us have written (or inherited):

@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = 'https://api.example.com/products';

  private readonly selectedId = signal<number | null>(null);

  readonly product = signal<Product | null>(null);
  readonly isLoading = signal(false);
  readonly error = signal<unknown>(null);

  private sub?: Subscription;

  selectProduct(id: number) {
    this.selectedId.set(id);
    this.fetchProduct(id);
  }

  private fetchProduct(id: number) {
    this.sub?.unsubscribe(); // cancel in-flight request
    this.isLoading.set(true);
    this.error.set(null);

    this.sub = this.http.get<Product>(`${this.baseUrl}/${id}`).subscribe({
      next: (product) => {
        this.product.set(product);
        this.isLoading.set(false);
      },
      error: (err) => {
        this.error.set(err);
        this.isLoading.set(false);
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

From the code snippet above, for every read endpoint you tend to re-implement:

  • set loading true before the call
  • clear / set errors in next / error
  • unsubscribe or switchMap so stale responses don't win
  • keep product, isLoading, and error in sync by hand

In this case, you are not just fetching data, but you end up maintaining state around the observable. A simple GET request becomes a tedious task to maintain as the app grows. Even the "cleaner" RxJS version still makes you own that state:

product$ = this.id$.pipe(
  switchMap((id) =>
    this.http.get<Product>(`/api/products/${id}`).pipe(
      map((product) => ({ state: 'loaded' as const, product })),
      catchError((error) => of({ state: 'error' as const, error })),
      startWith({ state: 'loading' as const }),
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

It can be argued that the above code is better, but still ceremonial; loading, error, status are things you have to manually manage and not things you get.

Resources: declare the request, read the state

A Resource ties async work to Angular signals. When dependencies change, it re-runs. While it does, it exposes synchronous signals you can bind in your templates:

Signal Meaning
value() Resolved data (or undefined)
isLoading() Request currently in flight
error() Failure, if any
status() Fine-grained state: idle, loading, resolved, error, reloading, …
hasValue() Type-safe "do we have data?"

httpResource: best default for HTTP reads

If you're already on Angular's HTTP stack (interceptors, HttpClient testing, etc.), prefer httpResource.

@Service()
export class ProductService {
  private readonly baseUrl = 'https://api.example.com/products';
  selectedId = signal<number>(0);

  productResource = httpResource<Product>(() => {
    const id = this.selectedId();
    if (!id) return undefined; // no request until we have an id
    return `${this.baseUrl}/${id}`;
  });

  // Declarative projections — no subscriptions
  product = computed(() => this.productResource.value());
  isLoading = this.productResource.isLoading;
  error = computed(() => this.productResource.error());
  status = this.productResource.status;
}
Enter fullscreen mode Exit fullscreen mode

What you no longer monkey-patch

1. Loading

@if (isLoading()) {
  <p>Loading…</p>
}
Enter fullscreen mode Exit fullscreen mode

No isLoading.set(true) before the call. The resource owns that.

2. Errors

@else if (error()) {
  <p>Something went wrong.</p>
}
Enter fullscreen mode Exit fullscreen mode

No dual path in subscribe({ next, error }) just to feed a signal.

3. Status (including reload vs first load)

@if (status() === 'reloading') {
  <small>Refreshing…</small>
}
Enter fullscreen mode Exit fullscreen mode

That's hard to express cleanly when you're manually toggling one boolean.

4. Reactive inputs

When you change selectedId, the resource refetches. In-flight work is handled in a switchMap-like way: a new request supersedes the previous one. You don't wire switchMap + teardown yourself for the common case.

5. Idle / skip

Return undefined from the request function when you don't want a call yet (empty id, missing filter). This is a declarative way of saying "don't fetch" and not an if around http.get buried in a method.

Template stays lean

@if (product(); as p) {
  <h1>{{ p.title }}</h1>
  <p>{{ p.price }}</p>
} @else if (isLoading()) {
  <p>Loading product…</p>
} @else if (error()) {
  <p>Could not load product.</p>
}
Enter fullscreen mode Exit fullscreen mode

In this case, the UI reads state.

rxResource: when you still want Observables

httpResource is great for URL / request-object GETs. Sometimes you need the RxJS toolbox: forkJoin, custom map pipelines, merging multiple sources, richer retry logic, etc.

That's what rxResource is for; same resource surface (value, isLoading, error, status), but the loader returns an Observable.

productResource = rxResource({
  params: () => this.selectedId(),
  stream: ({ params: id }) => {
    if (!id) return EMPTY;
    return this.http.get<Product>(`${this.baseUrl}/${id}`).pipe(
      // RxJS where it earns its keep
      retry(1),
      map((product) => ({
        ...product,
        displayPrice: formatPrice(product.price),
      })),
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

Mental model:

API Use when
httpResource Declarative HTTP reads via URL / request config; want interceptors + resource signals
rxResource Need Observable operators / composition, but still want resource state signals
resource Promise / fetch-style loaders outside HttpClient
plain HttpClient Mutations, one-off calls, or flows that aren't "track params → keep view state"

Resources shine for retrieval wired to reactive inputs. For POST / PUT / DELETE, plain HttpClient (or a mutation wrapper) is still the usual tool — then refresh a resource with .reload() if the UI list/detail should update.

Side-by-side comparison

Observable style where you own the state machine

// You create and sync these
product = signal<Product | null>(null);
isLoading = signal(false);
error = signal<unknown>(null);

// You remember to toggle them on every branch
this.isLoading.set(true);
this.http.get<Product>(url).subscribe({
  next: (p) => { this.product.set(p); this.isLoading.set(false); },
  error: (e) => { this.error.set(e); this.isLoading.set(false); },
});
Enter fullscreen mode Exit fullscreen mode

Resource style where the state machine is the API

productResource = httpResource<Product>(() => {
  const id = this.selectedId();
  if (!id) return undefined;
  return `https://api.example.com/products/${id}`;
});

product = computed(() => this.productResource.value());
isLoading = this.productResource.isLoading;
error = computed(() => this.productResource.error());
Enter fullscreen mode Exit fullscreen mode

What about the async pipe?

The async pipe already removed a lot of subscribe boilerplate in templates. However, it did not give you a standard loading/error/status model as you had to manually implement that yourself.

You still wrapped streams in view-models, used *ngIf(pre-modern control flow syntax) with as, or pulled in libraries to represent pending/error/success. Resources make that triad a first-class, signal-friendly contract, especially nice next to signal inputs, computed, and modern control flow (@if, @for).

Practical guidance

  1. Reading data driven by signals / route params → start with httpResource.
  2. Need RxJS operators in the loader → rxResource.
  3. Writing data (create/update/delete) → HttpClient methods; reload or update resources afterward.
  4. Template loading UI → prefer hasValue() / value() for content, use isLoading() as a secondary indicator (especially during reloading), don't invent a third set of flags unless you must.

Conclusion

The commonly known HttpClient + Observables aren't wrong. They're low-level. You get a stream of values; you build loading, error, cancellation, and status around them.

httpResource and rxResource raise the abstraction to match how UI actually works:

When these inputs change, fetch that. Here's the data, whether we're loading, and what went wrong — as signals.

Top comments (0)