Ch. 4 · Angular

Angular Resource API for Async Data

How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.

~6 min readadvancedupdated Oct 19, 2026

Before signals, loading data in an Angular component meant RxJS: a BehaviorSubject for the search term, switchMap to the HTTP call, catchError to keep the stream alive, a startWith for the loading flag, and async pipes in the template. It works, and every team writes it slightly differently. The resource() API, introduced as experimental in Angular 19, packages that pattern into one object whose state you read through signals.

You give it two things: a params function that reads signals and produces a request, and a loader that turns the request into a promise. Angular re-runs the loader whenever the request changes and exposes the outcome as value(), status(), error(), isLoading() and hasValue().

Note

The option names changed between versions. Angular 19 called them request and loader({ request }), and status() returned a ResourceStatus enum. Angular 20 renamed request to params and switched status to string values such as 'loading'. The API was still experimental in 19 and 20; the current API reference lists resource as stable from v22. Examples here use the Angular 20 shape.

The shape of a resource

Here’s a product search box. It loads results once the user has typed at least two characters.

@Component({
  selector: 'app-product-search',
  templateUrl: './product-search.html',
})
export class ProductSearch {
  private readonly api = inject(ProductApi);
  readonly query = signal('');

  readonly results = resource({
    params: () => (this.query().length >= 2 ? { q: this.query() } : undefined),
    loader: ({ params, abortSignal }) => this.api.search(params.q, abortSignal),
  });
}
TypeScript

Three behaviours are packed into those few lines.

params is reactive, like computed. It reads this.query(), so any change to query produces a new request object and schedules a new load. You don’t subscribe to anything.

Returning undefined means “nothing to load”. With fewer than two characters, params returns undefined, the loader isn’t called, and the status is 'idle'. That’s how you express “don’t fetch until the user has typed enough” or “wait until the user ID is known”.

A new request aborts the previous one. The loader receives an AbortSignal. When params changes while a load is still running, Angular aborts that signal. Passing it to fetch(url, { signal }) cancels the network request; even if you ignore it, the stale result is discarded rather than overwriting the newer one. This is the race condition switchMap used to solve, handled for you.

Walking through the states with real timings

To see the transitions, the resource above was run in a zoneless Angular 20 TestBed with a fake API where "an" takes 300 ms to answer and every other query takes 50 ms or less. Each line shows status() and value() at that moment (the query "boom" makes the fake API throw HTTP 500, and the last step calls results.set(['manual'])):

empty query                idle       undefined
typed "an"                 loading    undefined
typed "ang" quickly        loading    undefined
after 120ms                resolved   ["ang-1","ang-2"]
after 420ms                resolved   ["ang-1","ang-2"]
reload()                   reloading  ["ang-1","ang-2"]
reload done                resolved   ["ang-1","ang-2"]
typed "angular"            loading    undefined
angular done               resolved   ["angular-1","angular-2"]
boom                       error      THROWS: Resource is currently in an error state (see Error.cause for details): HTTP 500
set locally                local      ["manual"]
Text

The API log for the same run read start an | abort an | start ang | start ang | start angular | start boom. Read the table carefully, because each row answers a question interviewers ask.

  • Rows 2 to 5. The slow "an" request was aborted the moment "ang" arrived. At 420 ms, long after "an" would have finished, the results are still for "ang". No stale overwrite.
  • Row 6. reload() re-runs the loader with the same params. The status is 'reloading', and the previous value stays visible, so a refresh button doesn’t blank the list.
  • Row 8. A params change is different: the status is 'loading' and value() drops to undefined. If your template renders the list from value(), it flashes empty on every keystroke.
  • Row 10. In the error state, calling value() throws: Resource is currently in an error state (see Error.cause for details): HTTP 500. The underlying error is on error(). That’s why hasValue() exists: it’s both a type guard and a safe check before reading.
  • Row 11. set() and update() write a local value and put the status in 'local'. Useful for optimistic edits; the next params change replaces it.

Rendering it without flicker or crashes

The @switch on status is the straightforward template:

<input [value]="query()" (input)="query.set($any($event.target).value)" placeholder="Search products" />

@switch (results.status()) {
  @case ('idle') { <p>Type at least two characters.</p> }
  @case ('loading') { <p>Searching…</p> }
  @case ('error') { <p>Search failed. <button (click)="results.reload()">Try again</button></p> }
  @default {
    <ul>
      @for (p of results.value() ?? []; track p.id) { <li>{{ p.name }}</li> }
    </ul>
  }
}
HTML

That works, but it shows “Searching…” in place of the list on every keystroke. For search-as-you-type, users prefer the old results to stay visible, slightly dimmed, until new ones arrive. linkedSignal can hold on to the last good value:

readonly shown = linkedSignal<Product[] | undefined, Product[]>({
  source: () => (this.results.hasValue() ? this.results.value() : undefined),
  computation: (next, previous) => next ?? previous?.value ?? [],
});
TypeScript

In the same test run, shown() kept ["ang-1","ang-2"] while "angular" was loading, then switched to the new results, and it kept the last good list during the 500 error. Render the list from shown(), add a .is-stale class while results.isLoading() is true, and show the error message alongside rather than instead of the list.

What resource doesn’t do for you

It doesn’t debounce. Every change to query starts a request (and aborts the last). Aborting saves the browser from processing stale responses, but the server still receives every keystroke. Debounce the input before it reaches params, for example by setting query from a debounced event or by deriving a debounced signal with toSignal(toObservable(this.input).pipe(debounceTime(250))).

It isn’t for mutations. The docs are explicit that resource is meant for reads. Because a params change aborts the in-flight loader, a “save” modelled as a resource can be cancelled halfway. Call a service method for writes, then call reload() or set() on the resource to reflect the result.

It doesn’t share or cache across components. Two components with identical resources make two requests. If several screens need the same data, put the resource in a service provided where those screens can reach it.

For HTTP specifically, httpResource (added in Angular 19.2) builds on HttpClient, so interceptors and HttpTestingController work with it:

readonly results = httpResource<Product[]>(() =>
  this.query().length >= 2 ? `/api/products?q=${encodeURIComponent(this.query())}` : undefined,
);
TypeScript

With plain resource and fetch, interceptors are bypassed, which matters if auth headers come from an interceptor.

Interview exercise: profile and company

“A page shows a user’s profile and their company. The company ID comes from the profile response. How would you model this with resources, and what happens while the profile is reloading?”

Answer. I’d make two resources. The profile resource takes the user ID from the route as params. The company resource’s params returns undefined until the profile has a value, then returns the company ID: params: () => this.profile.hasValue() ? this.profile.value().companyId : undefined. Returning the bare ID rather than { companyId } matters, because an object wrapping undefined is still a defined request and would call the loader with no ID. While the profile is in 'loading' after a route change, hasValue() is false, so the company resource goes 'idle' and the company section should show a placeholder, not the previous user’s company. During a plain reload() the profile keeps its value, so the company resource doesn’t reload unless the ID actually changed. Newer Angular versions add a chain() helper in the params context that propagates the parent’s loading and error status automatically; on 19 or 20, the hasValue() check is the portable way. In the template I’d guard every value() read with hasValue(), because a failed profile request would otherwise throw during rendering.

Further study

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
read ✓Angular · hard

Angular Zoneless Change Detection

Without zone.js, Angular renders only when notified. See which updates notify, which silently don't, and how TestBed catches the gaps for you.

~6 min readread →
esc