Angular PrimerAngular 22 · 2022 → 2026

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

2026 — app.config.ts
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

@Injectable() export class AuthInterceptor implements HttpInterceptor { constructor(private auth: AuthService) {} intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${this.auth.token()}` } })); } }

2026 — a functional interceptor

export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); return next(req.clone({ setHeaders: { Authorization: `Bearer ${auth.token()}` } })); };

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:

2026 — a resource with parameters
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:

2026 — the states, in the template
@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

loading = true; data?: Page; error?: string; private sub?: Subscription; ngOnInit() { this.sub = this.route.paramMap.pipe( switchMap((params) => this.http.get<Page>(`/api/pages/${params.get('slug')}`)), ).subscribe({ next: (data) => { this.data = data; this.loading = false; }, error: (err) => { this.error = err.message; this.loading = false; }, }); } ngOnDestroy() { this.sub?.unsubscribe(); }

2026 — one resource, no subscription

readonly slug = input.required<string>(); readonly page = resource({ params: () => ({ slug: this.slug() }), loader: ({ params, abortSignal }) => fetchPage(params.slug, abortSignal), });

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:

2026 — httpResource
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:

2026 — bridging a stream
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.