DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Angular 20: Querying Data with `rxResource` — from `request/loader` to `params/stream`

Angular 20: Querying Data with  raw `rxResource` endraw  — from  raw `request/loader` endraw  to  raw `params/stream` endraw

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:

  • requestparams
  • loaderstream (used for both querying and streaming)
  • ResourceStatusstring 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
});
Enter fullscreen mode Exit fullscreen mode
  • Use params to derive fetch inputs from signals.
  • Implement stream to 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(); }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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/loader in multiple files, a mechanical rename to params/stream covers most of the migration.


Expert Tips & Pitfalls

  • Always type your resource: rxResource<Result, Params>. This prevents {}.foo errors in templates.
  • Provide defaultValue to eliminate undefined guards in HTML.
  • Abort stale requests: HttpClient supports { signal: abortSignal } — Angular cancels when params change.
  • Prefer stable track keys in @for (use an id field, 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))
        );
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Resources


Happy querying! If you’d like the streaming counterpart (server events, websockets) with rxResource in Angular 20.

Top comments (2)

Collapse
 
shemith_mohanan_6361bb8a2 profile image
shemith mohanan

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!

Collapse
 
hashbyt profile image
Hashbyt

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.