HTTP & async data
`resource()` turns "fetch, show, handle failure" into one object with signals for each state — instead of a loading boolean, an error field, and a subscription to remember.
The client, provided
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch(), withInterceptors([authInterceptor, timingInterceptor])),
],
};HttpClientModule is gone; withFetch() opts into the browser's Fetch API instead of XMLHttpRequest (and is the default in new projects since v20), which is what makes HttpClient usable during server-side rendering.
Interceptors are functions
2022 — a class interceptor
2026 — a functional interceptor
Two things get easier: the interceptor can inject() anything it needs without a constructor, and registration is an array passed to withInterceptors rather than a provider with HTTP_INTERCEPTORS and multi: true.
A functional interceptor, live
The interceptor clones the request and adds a header; the log shows the request leaving and the response coming back. The backend is simulated locally, so the demo needs no server.
status() = idle
The header only exists because the interceptor cloned the request. Angular calls interceptors for you; this demo calls it directly over a simulated backend so it works without a server.
resource(): the whole lifecycle as signals
This is the change that matters most day to day. Loading state, the value, the error, and a way to reload — all readable in the template:
export class Topics {
readonly slug = input.required<string>();
readonly page = resource({
params: () => ({ slug: this.slug() }), // re-runs when this changes
loader: ({ params, abortSignal }) =>
fetch(`/api/pages/${params.slug}`, { signal: abortSignal }).then((r) => r.json()),
});
}What you read from it:
@if (page.isLoading()) {
<p>Loading…</p>
} @else if (page.error()) {
<p>That page could not be loaded.</p>
<button type="button" (click)="page.reload()">Try again</button>
} @else {
<article>{{ page.value()?.title }}</article>
}The signal surface is small and consistent: value(), status() (idle | loading | reloading | resolved | error | local), isLoading(), error(), and reload(). params makes the resource reactive — change the parameter and the load restarts, with the in-flight request aborted for you through abortSignal.
2022 — a boolean, a field, and a subscription
2026 — one resource, no subscription
The second version cannot leak a subscription, cannot show a stale page for a parameter that has already changed, and cannot forget to reset loading on the error path — the three bugs that version one is famous for.
resource(): the lifecycle as signals
status(), isLoading(), value() and error() are all readable, and the loader is a promise you control — press the buttons to walk the whole state machine and back.
status() = resolved · isLoading() = false
Quarterly report · 42 lines
The loader is a promise, so the demo can drive the whole lifecycle from a button: loading, value, error, and back. In an app the loader would be your fetch or your service call — the state machine around it is the same either way.
httpResource(): when it is just a GET
For the common case — a URL, a typed body — httpResource is the shorter form and it still gives you the same signal states:
readonly results = httpResource<Result[]>(() => `/api/search?q=${this.query()}`);It returns a typed resource, so results.value() is Result[] | undefined and results.error() is typed too. Because it goes through HttpClient, your interceptors apply — which is why it is usually the right default over fetch in a resource.
For an Observable-based pipeline you would rather keep in RxJS, rxResource takes a loader returning an observable and gives you the same signal interface:
readonly results = rxResource({
params: () => this.query(),
stream: ({ params }) => this.api.search(params).pipe(debounceTime(300), distinctUntilChanged()),
});httpResource(): when it is just a GET
A real HTTP GET against the fixture file this site ships. No loader, no status handling, no subscription: the request is the signal.
status() = resolved · isLoading() = false
Quarterly report · 42 lines
This one is a real HTTP GET — watch the network panel, or open the fixture directly. No loader, no status handling, no subscription: the request is the signal.
Choosing between them
httpResource— a GET whose URL is derived from signals. Start here.resource— anything the client cannot express:fetch, IndexedDB, a WASM call, a chain you want to write by hand.rxResource— the logic genuinely needs RxJS operators (retry with backoff, websockets, complex combination).toSignal— you have an existing observable from somewhere else and simply want its latest value in a signal.
The remaining trap is not in resource, it is in the habits around it. Do not call subscribe() inside a loader, do not read .value() and treat undefined as "empty" when it actually means "not loaded yet" (that is what isLoading() and status() are for), and do not kick off a load from an effect() — a resource with params already does that, and the effect version will fire twice in development.
What to take away
Data loading stopped being a lifecycle problem and became a state problem: one object, a handful of signals, and a template that says what each state looks like. The same declarative style reaches forms in Forms, and it is what makes a zoneless app possible at all — see Zoneless change detection.