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);
},
});
}
}
From the code snippet above, for every read endpoint you tend to re-implement:
- set loading
truebefore the call - clear / set errors in
next/error - unsubscribe or
switchMapso stale responses don't win - keep
product,isLoading, anderrorin 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 }),
),
),
);
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;
}
What you no longer monkey-patch
1. Loading
@if (isLoading()) {
<p>Loading…</p>
}
No isLoading.set(true) before the call. The resource owns that.
2. Errors
@else if (error()) {
<p>Something went wrong.</p>
}
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>
}
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>
}
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),
})),
);
},
});
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); },
});
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());
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
-
Reading data driven by signals / route params → start with
httpResource. -
Need RxJS operators in the loader →
rxResource. -
Writing data (create/update/delete) →
HttpClientmethods; reload or update resources afterward. -
Template loading UI → prefer
hasValue()/value()for content, useisLoading()as a secondary indicator (especially duringreloading), 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)