06 · Async Data with resource & httpResource¶
Loading data in a component used to mean the same boilerplate every time: a loading
signal, an error signal, a data signal, a subscription, cancelling stale requests
when inputs change, and remembering to reset everything in each branch. The resource
APIs package that pattern. You describe what to load as a function of signals, and get
back an object with value, status, error and isLoading signals that stay correct
automatically.
Three flavours, all stable (@publicApi 22.0) in Angular 22:
| API | Loader returns | Package |
|---|---|---|
resource({ params, loader }) |
a Promise |
@angular/core |
rxResource({ params, stream }) |
an Observable |
@angular/core/rxjs-interop |
httpResource(() => url or request) |
— it makes an HttpClient GET for you |
@angular/common/http |
httpResource: the common case¶
import { Component, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
interface User { id: number; name: string; email: string }
@Component({
selector: 'app-user-card',
template: `
@if (user.error(); as err) {
<p role="alert">Could not load user {{ id() }}.</p>
<button type="button" (click)="user.reload()">Try again</button>
} @else if (user.hasValue()) {
<h2>{{ user.value().name }}</h2>
<p>{{ user.value().email }}</p>
}
@if (user.isLoading()) {
<p aria-live="polite">Loading…</p>
}
`,
})
export class UserCard {
readonly id = input.required<number>();
protected readonly user = httpResource<User>(() => `/api/users/${this.id()}`);
}
That's the whole data layer for this component. When id changes, the resource
computes the new URL and fetches it; if a request is still in flight for the previous
id, it's cancelled. We rendered it under the HTTP testing backend and recorded the text
at each step:
T1 Loading… (request for /api/users/1 outstanding)
T2 Ada ada@example.com (flushed 200)
T3 Could not load user 2. Try again (id → 2, flushed 500)
T4 Could not load user 2. Try again Loading… (clicked Try again → reload())
T5 Grace grace@example.com (flushed 200)
The request function can return a URL string or a request object —
{ url, method, params, headers, body, ... } — for anything beyond a simple GET. Returning
undefined means "nothing to load right now" and leaves the resource idle:
protected readonly query = signal('');
protected readonly results = httpResource<User[]>(
() => (this.query() ? { url: '/api/users', params: { q: this.query() } } : undefined),
{ defaultValue: [] },
);
With an empty query this stayed idle with value []; setting query to gr sent
GET /api/users?q=gr and resolved to the flushed array.
httpResource also exposes the response's headers() and statusCode() as signals, and
has variants for non-JSON bodies: httpResource.text(), httpResource.blob(),
httpResource.arrayBuffer(). Pass parse: (raw) => ... to validate or transform the
JSON (a natural place for a schema library).
Reads, not writes
httpResource is designed for fetching data that depends on signals. For POST,
PUT and DELETE triggered by a user action, call HttpClient directly (or use
submit() in signal forms). A resource re-runs whenever its params change, which
is exactly wrong for "create an order".
resource: any Promise-based loader¶
const r = resource({
params: () => id(), // undefined → idle
loader: async ({ params, abortSignal }) => {
const res = await fetch(`/api/users/${params}`, { signal: abortSignal });
if (!res.ok) throw new Error(`User ${params} not found`);
return (await res.json()) as User;
},
});
The loader receives params (already non-undefined — the type excludes undefined),
an abortSignal that fires when the result is no longer wanted, and previous (the
status before this load). Pass the signal to fetch or any API that accepts one.
We ran a resource whose loader took 20 ms, and logged its state along the way:
A idle value: undefined hasValue: false params() returned undefined
B loading id set to 1
C resolved {"id":2,"name":"User 2"} aborted: [1]
D error "User 13 not found" hasValue: false
E local {"id":2,"name":"Renamed locally"}
Line C is the interesting one: we set the id to 1 and then to 2 before the first load
finished. The first load's abortSignal fired (aborted: [1]) and only user 2's result
was ever shown — no race condition to handle yourself.
Status and value, precisely¶
status() |
Meaning | value() |
|---|---|---|
idle |
params returned undefined |
defaultValue (or undefined) |
loading |
loading after params changed | defaultValue (or undefined) — the old value is cleared |
reloading |
loading after reload() |
the previous value is kept |
resolved |
loaded successfully | the loaded value |
error |
loader threw / request failed | reading it throws |
local |
you called value.set(...) / update(...) |
your value |
That error row is easy to trip over. Reading value() in the error state threw:
So in templates, check error() first or guard with hasValue() — as UserCard does —
rather than reading value() unconditionally. hasValue() is also a type guard: inside
@else if (user.hasValue()), user.value() is typed without undefined.
Local edits. A resource's value is writable. Setting it switches the status to
local; the next params change or reload() replaces it with server data again. It's a
convenient place for optimistic updates.
Defaults. defaultValue: [] makes the value type User[] instead of
User[] | undefined. Note it's used while idle or loading, so an empty list and "not
loaded yet" look the same — show isLoading() if the difference matters.
Chaining resources¶
When one request needs the result of another, the params function receives a context
with chain:
protected readonly org = httpResource<Org>(() => `/api/orgs/${this.orgId()}`);
protected readonly members = resource({
params: ({ chain }) => chain(this.org)?.membersUrl,
loader: ({ params }) => fetch(params).then((r) => r.json() as Promise<Member[]>),
});
Per the API docs, chain(other) returns the other resource's value only when it is
resolved or local, and otherwise propagates its status — so members shows
loading while org loads, and error if org fails, without extra code.
The ?. is needed: an httpResource without a defaultValue is typed Org | undefined,
and without it the build failed with TS2532: Object is possibly 'undefined'. Returning
undefined from params simply leaves members idle.
Testing components that use resources¶
Use provideHttpClient() + provideHttpClientTesting() and flush requests from the test.
One trap we hit: await fixture.whenStable() does not resolve while a resource
request is outstanding. In a test that awaited it before flushing, it was still pending
after 500 ms; after flushing, it resolved immediately. Resources register a pending task
with Angular while loading, and "stable" means "no pending tasks". So flush first, then
await stability — or advance with TestBed.tick().
How It Actually Works¶
A resource is a small state machine built out of signals:
paramsis wrapped in acomputed. An internal effect watches it.- When the computed produces a new value, the resource aborts the previous load's
AbortController, sets status toloading(oridleforundefined), and calls the loader with the new params and a freshabortSignal. - When the promise settles, the resource checks that the result still belongs to the
current request (a stale result is dropped), then sets
value/errorandstatus. - While a load is pending, it holds a
PendingTasksentry, which keeps the application "unstable" — this is what server-side rendering waits for before serialising HTML (Level 3, lesson 06), and whatwhenStable()waits for in tests.
httpResource builds the params computed from your request function and uses a loader
that makes the request through HttpClient — so interceptors (lesson 08), the test
backend and the default fetch backend all apply. rxResource uses a streaming loader
that subscribes to your observable and unsubscribes on the next change.
Because status, value and error are signals, templates and computeds that read
them participate in normal change detection. There's nothing to subscribe to and nothing
to unsubscribe from.
Common mistakes¶
- Reading
value()in the error state. It throws. Guard witherror()/hasValue(). - Using
httpResourcefor mutations. It re-fetches when params change; that's not what a POST should do. - Forgetting that
loadingclears the value. Lists flash empty on every parameter change unless you keep the old value (Level 3 showslinkedSignalfor this) or show a skeleton. - Ignoring
abortSignalinresourceloaders. The resource ignores stale results anyway, but without passing the signal tofetch, the network request still runs. - Awaiting
whenStable()before flushing test requests.
Exercise¶
- Build a
RepoListcomponent withusername = input.required<string>()and anhttpResourceforhttps://api.github.com/users/{username}/repos(a real public endpoint — note GitHub rate-limits unauthenticated requests, so expect occasional 403s and handle them). - Show loading, error-with-retry and list states. Make sure nothing reads
value()in the error state. - Add a
sort = signal<'stars' | 'name'>('stars')and derive the sorted list withcomputed. Explain why sorting belongs in acomputed, not in the request. - Write a test with
provideHttpClientTesting()that flushes a 404 and asserts the error message renders.