Angular 20: Querying Data with rxResource — from request/loader to params/stream
Angular 20 ships a refined Resource API. If you used resource()/rxResource() in v19, two names changed and status got simpler:
-
request→params -
loader→stream(used for both querying and streaming) -
ResourceStatus→ string union ('idle' | 'loading' | 'reloading' | 'resolved' | 'error')
This post is a hands‑on guide to querying/paginating data with rxResource in Angular 20 using Signals, with typed results, status UIs, and a one‑file demo you can paste.
TL;DR
import { rxResource } from '@angular/core/rxjs-interop';
const pageRef = rxResource<Page, QueryParams>({
params: () => queryParams(), // reactive input (was `request`)
stream: ({ params, abortSignal }) => // Observable/Promise factory (was `loader`)
http.get<Page>('/api/items', { params: params(), signal: abortSignal }),
defaultValue: { items: [], total: 0 }, // avoid undefined in templates
});
- Use
paramsto derive fetch inputs from signals. - Implement
streamto return an Observable or Promise of your typed data. - Read
.value(),.status(),.error(),.isLoading(), and call.reload().
Complete Example — Pokémon Pagination with rxResource
This component paginates Pokémons using pokeapi.co. It demonstrates:
- URL‑like query signals (
q,limit,offset) - A typed
rxResource<Page, Query> - UI for loading/error/resolved states
- Prev/Next pagination with status transitions
Paste this into a fresh standalone component, import it in a route, and go.
pokemon-page.component.ts
import { Component, computed, effect, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { rxResource } from '@angular/core/rxjs-interop';
import { NgIf, NgFor } from '@angular/common';
import { map, mergeMap, forkJoin, of, catchError } from 'rxjs';
/** Domain models (trim as needed) */
interface PrePokemon { id: number; name: string; sprites?: { front_default?: string } }
interface PageUrlResponse { count: number; next: string | null; previous: string | null; results: Array<{ name: string; url: string }> }
interface Page { count: number; next: string | null; previous: string | null; results: PrePokemon[] }
@Component({
selector: 'app-pokemon-page',
standalone: true,
imports: [NgIf, NgFor],
templateUrl: './pokemon-page.component.html',
})
export class PokemonPageComponent {
private http = inject(HttpClient);
/** Query signals */
readonly limit = signal(6);
readonly page = signal(1);
readonly offset = computed(() => (this.page() - 1) * this.limit());
/** Compute the API URL like a router would do */
readonly url = computed(
() => `https://pokeapi.co/api/v2/pokemon?limit=${this.limit()}&offset=${this.offset()}`
);
/** Helper: hydrate list of Pokémon URLs into full Pokémon objects */
private paginate$(url: string) {
return this.http.get<PageUrlResponse>(url).pipe(
mergeMap((res) => {
const pokemonUrls = res.results.map(r => r.url);
const pokemons$ = pokemonUrls.map(u =>
this.http.get<PrePokemon>(u).pipe(catchError(() => of(undefined as unknown as PrePokemon)))
);
return forkJoin(pokemons$).pipe(
map(list => list.filter(Boolean)),
map(results => ({ ...res, results } as Page))
);
})
);
}
/** The Resource (v20 API: params + stream) */
readonly pageRef = rxResource<Page, string>({
params: () => this.url(),
stream: ({ params: url, abortSignal }) => this.paginate$(url), // HttpClient respects signal
defaultValue: { count: 0, next: null, previous: null, results: [] },
});
/** Convenience getters derived from the resource's value */
readonly value = computed(() => this.pageRef.value());
readonly nextUrl = computed(() => this.value().next ?? undefined);
readonly prevUrl = computed(() => this.value().previous ?? undefined);
/** Keep page in range when count changes */
constructor() {
effect(() => {
const total = this.value().count;
const pages = Math.max(1, Math.ceil(total / this.limit()));
if (this.page() > pages) this.page.set(pages);
});
}
/** UI handlers */
next() { this.page.update(p => p + 1); }
prev() { this.page.update(p => Math.max(1, p - 1)); }
reload() { this.pageRef.reload(); }
}
pokemon-page.component.html
<section class="p-6 space-y-4">
<header class="flex items-center gap-3">
<div class="text-sm opacity-70">Limit:</div>
<select class="select select-bordered select-sm"
(change)="limit.set(+$any($event.target).value)">
<option [selected]="limit()===6" value="6">6</option>
<option [selected]="limit()===12" value="12">12</option>
<option [selected]="limit()===24" value="24">24</option>
</select>
<button class="btn btn-sm" (click)="reload()" [disabled]="pageRef.isLoading()">Reload</button>
<span class="ml-auto text-sm opacity-70">
Status: {{ pageRef.status() }}
</span>
</header>
@if (pageRef.status() === 'loading' || pageRef.status() === 'reloading') {
<div class="flex items-center gap-2">
<span class="loading loading-spinner"></span>
<span>Loading…</span>
</div>
}
@if (pageRef.status() === 'error') {
<div class="alert alert-error">
<span>Failed to load: {{ pageRef.error()?.message ?? 'Unknown error' }}</span>
<button class="btn btn-sm ml-auto" (click)="reload()">Retry</button>
</div>
}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
@for (p of value().results; track p.id) {
<article class="card bg-base-200">
<figure class="aspect-square bg-base-300">
<img [src]="p.sprites?.front_default || 'https://placehold.co/160x160?text=PKMN'"
[alt]="p.name">
</figure>
<div class="card-body p-3">
<h3 class="card-title text-sm">{{ p.name | titlecase }}</h3>
</div>
</article>
}
</div>
<footer class="flex items-center gap-3">
<button class="btn" (click)="prev()" [disabled]="!prevUrl() || pageRef.isLoading()">Prev</button>
<div class="opacity-70">Page {{ page() }}</div>
<button class="btn" (click)="next()" [disabled]="!nextUrl() || pageRef.isLoading()">Next</button>
</footer>
</section>
Migration (v19 → v20)
| v19 name | v20 name | Notes |
|---|---|---|
request |
params |
Reactive function returning the fetch inputs (often built from signals). |
loader |
stream |
Returns an Observable or Promise. Also used for server‑sent/streaming scenarios. |
ResourceStatus (enum-ish) |
string union | `'idle' |
{% raw %}hasValue() checks |
still valid | You can also rely on defaultValue to avoid undefined altogether. |
Tip: If you had
request/loaderin multiple files, a mechanical rename toparams/streamcovers most of the migration.
Expert Tips & Pitfalls
-
Always type your resource:
rxResource<Result, Params>. This prevents{}.fooerrors in templates. -
Provide
defaultValueto eliminateundefinedguards in HTML. -
Abort stale requests: HttpClient supports
{ signal: abortSignal }— Angular cancels when params change. -
Prefer stable
trackkeys in@for(use anidfield, not$index). -
Keep side effects out of
stream; it should be a pure factory for the request Observable/Promise. -
Reload UX:
status() === 'reloading'is perfect for subtle spinners on a refresh action.
When to use resource() vs rxResource()
- Use
resource()when your fetch returns a Promise or you don’t need RxJS operators. - Use
rxResource()when your source is an Observable or you benefit from RxJS composition (debounce, mergeMap, retry, etc.).
Both share the same mental model and the params/stream names in v20.
Appendix: Minimal Service Version
If you prefer a service wrapper instead of composing inside the component:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map, mergeMap, forkJoin, of, catchError } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PokemonPageService {
private http = inject(HttpClient);
paginate(url: string) {
return this.http.get<PageUrlResponse>(url).pipe(
mergeMap((res) => {
const pokemonUrls = res.results.map(r => r.url);
const pokemons$ = pokemonUrls.map(u =>
this.http.get<PrePokemon>(u).pipe(catchError(() => of(undefined as unknown as PrePokemon)))
);
return forkJoin(pokemons$).pipe(
map(list => list.filter(Boolean)),
map(results => ({ ...res, results } as Page))
);
})
);
}
}
Resources
- Angular docs — Resource: https://next.angular.dev/guide/signals/resource#
- API:
resource: https://next.angular.dev/api/core/resource# - API:
rxResource: https://next.angular.dev/api/core/rxjs-interop/rxResource#
Happy querying! If you’d like the streaming counterpart (server events, websockets) with rxResource in Angular 20.

Top comments (2)
Great write-up! 🙌
The renaming from request/loader to params/stream makes far more sense after seeing your example. The Pokémon demo and defaultValue tip really simplify real-world usage. Would love a follow-up with debounced search or live data streaming examples!
Incredible deep dive into rxResource! Switching to params/stream is a smart move. The Pokémon pagination demo is a perfect, actionable example for v20 Signals data fetching.