logo
2026-04-06 Angular 6 min

httpResource and the Death of Manual HTTP Subscriptions in Angular

I’ve written the same pattern hundreds of times. Inject HttpClient. Subscribe in ngOnInit. Store the result in a property. Handle loading state manually. Remember to unsubscribe or use takeUntilDestroyed. Handle race conditions when the user changes filters faster than the server responds.

At Zuub, across the 3 microfrontend apps, the subscribe-then-teardown pattern was duplicated everywhere in our shared data layer. Every app had its own slightly different wrapper around HttpClient for fetching dental claims, patient records, Stripe payment data. Each one reinvented loading states and cleanup. It was the kind of duplication that makes you want to scream into the void.

httpResource kills all of it. Signal in, signal out. The framework handles the rest.


The Core Pattern

httpResource takes a reactive request function and returns a resource object with signal-based properties. When the input signals change, the request re-fires automatically.

import { httpResource } from '@angular/common/http';
import { signal, computed } from '@angular/core';

@Component({ /* ... */ })
export class PropertyListComponent {
  protected readonly cityFilter = signal('Montevideo');
  protected readonly priceMax = signal<number | null>(null);

  protected readonly properties = httpResource<Property[]>(() => {
    const city = this.cityFilter();
    const maxPrice = this.priceMax();

    let url = `/api/properties?city=${city}`;
    if (maxPrice !== null) {
      url += `&maxPrice=${maxPrice}`;
    }
    return url;
  });
}

That’s it. No subscribe(). No ngOnInit. No ngOnDestroy. The resource reads cityFilter and priceMax inside its request function, auto-tracks them, and re-fetches whenever either changes.

Now imagine this in a real scenario. At Zuub, we had services that fetched dental insurance claim statuses and Stripe payment confirmations. A claim lookup depends on patient ID, provider, and date range. A payment status check depends on the Stripe session ID. With the old pattern, each of these was 30+ lines of subscribe/unsubscribe/loading-flag ceremony. With httpResource, each one collapses into a single declarative block. Multiply that by dozens of data-fetching services across a monorepo and the reduction in boilerplate is staggering.

In the template:

@if (properties.isLoading()) {
  <app-spinner />
}

@for (property of properties.value() ?? []; track property.id) {
  <app-property-card [property]="property" />
}

@if (properties.error()) {
  <p>Failed to load properties.</p>
}

The resource gives you .value(), .isLoading(), .error(), and .status() — all as signals. Your template just reads them. No async pipe, no subscription management.


Deactivating Requests with undefined

Sometimes you don’t want the resource to fetch at all. Maybe a required filter hasn’t been set yet. Return undefined from the request function and the resource goes idle.

protected readonly selectedAgentId = signal<string | null>(null);

protected readonly agentListings = httpResource<Listing[]>(() => {
  const agentId = this.selectedAgentId();

  if (!agentId) {
    return undefined; // No request — resource stays idle
  }

  return `/api/agents/${agentId}/listings`;
});

When selectedAgentId is null, no HTTP request is made. The resource sits in idle status. The moment a value is set, it fires. This replaces the filter(Boolean) + switchMap pattern that RxJS required.


Race Conditions: switchMap vs exhaustMap

This is the most important behavioral detail of httpResource, and the one most people miss.

When input signals change reactively, the resource uses switchMap-like semantics. If a request is in flight and the signals change, the in-flight request is cancelled and a new one fires. The user changed the city filter? The old request is gone. Only the latest matters.

When you call .reload() manually, the resource uses exhaustMap-like semantics. If a request is already in flight, the reload is ignored. It won’t stack up duplicate requests.

protected readonly listings = httpResource<Listing[]>(() =>
  `/api/listings?city=${this.cityFilter()}`
);

// Reactive change — cancels in-flight request, fires new one (switchMap)
this.cityFilter.set('Punta del Este');

// Manual reload — ignored if request already in flight (exhaustMap)
this.listings.reload();

Why does this matter? Because it means Angular makes different assumptions about intent. A signal change means “the query is different now, the old results are stale, start over.” A manual reload means “give me fresh data for the same query, but don’t spam the server.”

Understanding this distinction is critical for debugging unexpected behavior. If you’re calling reload() and nothing happens, there’s probably a request already in flight. If you’re changing a signal and the old data briefly shows, you might be looking at the gap between cancellation and the new response arriving.


Resource Status Values

The .status() signal returns one of six values, and they cover every state you need:

StatusMeaning
idleRequest function returned undefined, no fetch happening
loadingFirst fetch in progress, no previous data
reloadingSubsequent fetch in progress, previous data still available
errorFetch failed
resolvedFetch succeeded, data available
localValue was set locally via .set() or .update()

The distinction between loading and reloading is what lets you build UIs that don’t flash empty states when refreshing existing data. Show a subtle spinner overlay during reloading instead of replacing the whole list with a skeleton.

protected readonly isRefreshing = computed(() =>
  this.listings.status() === 'reloading'
);

The Writable Value Signal

Here’s something that surprised me: the resource’s .value() signal is writable. You can .set() or .update() it directly to create a local working copy.

protected readonly profile = httpResource<UserProfile>(() =>
  `/api/users/${this.userId()}`
);

updateName(newName: string): void {
  // Optimistic update — immediately reflect in UI
  this.profile.value.update(current =>
    current ? { ...current, name: newName } : current
  );

  // Then persist to server
  this.http.put(`/api/users/${this.userId()}`, { name: newName }).subscribe({
    error: () => this.profile.reload() // Revert on failure
  });
}

After calling .set() or .update(), the status changes to local. This is perfect for optimistic updates — show the change immediately, persist in the background, and reload() to revert if the server rejects it.


httpResource Is Read-Only by Design

This is a hard boundary: httpResource is only for data retrieval. GET requests. Fetching state from the server.

For mutations — POST, PUT, DELETE — use HttpClient directly. This is intentional. Mutations have different semantics: they need explicit error handling, confirmation flows, retry logic, and they don’t fit the “reactive re-fetch when inputs change” model.

// Fetching — use httpResource
protected readonly properties = httpResource<Property[]>(() =>
  `/api/properties?city=${this.cityFilter()}`
);

// Mutation — use HttpClient directly
private http = inject(HttpClient);

deleteProperty(id: string): void {
  this.http.delete(`/api/properties/${id}`).subscribe({
    next: () => this.properties.reload(),
    error: (err) => this.toastService.showError('Failed to delete')
  });
}

Don’t try to force mutations through resources. It’s the wrong abstraction.


The Three Resource Types

Angular actually provides three resource functions, each at a different abstraction level:

httpResource — the highest level. Takes a URL (or request config) and returns typed data. Uses HttpClient under the hood, so all your interceptors — auth headers, error handling, logging — still work.

rxResource — takes an Observable-based loader function. Use this when you need RxJS operators in your fetch pipeline, or when wrapping existing service methods that return Observables.

resource — the lowest level. Takes a Promise-based loader with an AbortSignal for cancellation. Use this for non-HTTP async operations or when you want full control.

// httpResource — simple URL-based fetching
const listings = httpResource<Listing[]>(() => `/api/listings`);

// rxResource — Observable-based, for complex pipelines
const listings = rxResource<Listing[]>({
  request: () => this.cityFilter(),
  loader: ({ request: city }) =>
    this.listingService.search(city).pipe(
      retry(2),
      catchError(() => of([]))
    )
});

// resource — Promise-based with AbortSignal
const listings = resource<Listing[]>({
  request: () => this.cityFilter(),
  loader: async ({ request: city, abortSignal }) => {
    const response = await fetch(`/api/listings?city=${city}`, {
      signal: abortSignal
    });
    return response.json();
  }
});

Start with httpResource. Drop down to rxResource when you need operators. Drop to resource when you need raw control.


Runtime Validation with Zod

Because httpResource returns typed data based on your generic parameter, it’s easy to assume the server response matches your TypeScript interface. It might not. The parse option lets you validate at runtime:

import { z } from 'zod';

const PropertySchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number().positive(),
  city: z.string(),
  rooms: z.number().int().min(0),
});

type Property = z.infer<typeof PropertySchema>;

protected readonly properties = httpResource<Property[]>(() =>
  `/api/properties?city=${this.cityFilter()}`, {
    parse: (data) => z.array(PropertySchema).parse(data)
  }
);

If the server returns data that doesn’t match the schema, the resource enters error status instead of silently passing bad data through your application. This is especially valuable when working with third-party APIs or backends you don’t control.


Interceptors Still Work

Since httpResource uses HttpClient internally, every interceptor you’ve configured still applies. Auth tokens get attached. Error responses get caught. Logging happens. Caching interceptors work.

This means you can adopt httpResource incrementally without reworking your HTTP infrastructure. Everything downstream of HttpClient is unchanged.

// Your existing interceptor still fires for httpResource requests
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();
  const cloned = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` }
  });
  return next(cloned);
};

Final Thoughts

httpResource is not a replacement for HttpClient. It’s a layer on top of it that handles the most common pattern — fetch data reactively, track loading state, handle errors — without any manual subscription management.

The key insight is the dual cancellation strategy: switchMap for reactive changes (new query, cancel the old one), exhaustMap for manual reloads (same query, don’t duplicate). Once that clicks, the rest is straightforward.

For data fetching, use httpResource. For mutations, use HttpClient. For complex async pipelines, drop down to rxResource or resource. Each tool has its place, and Angular finally gives you the right abstraction for the most common case.

If you maintain shared service libraries in an Nx monorepo, this is where httpResource really shines. You can define a data-fetching resource once in a shared library and consume it in any microfrontend app. The resource handles its own lifecycle, so the consuming app does not need to worry about cleanup. No more “did this app remember to unsubscribe?” audits. The shared library becomes truly self-contained.

No more subscribe(). No more takeUntilDestroyed(). No more manual loading flags. It’s about time.