A practical walkthrough of resource(), rxResource(), httpResource(), and the new debounced() utility — with working code and tests
Have you ever opened an Angular changelog and felt like you were reading a different framework than the one you learned three years ago? That's exactly what Angular 22 feels like. Released on June 3, 2026, it's the release where signals stop being "the new thing" and officially become "the only thing."
If you've been putting off learning signals because resources and forms were still marked experimental, that excuse is gone. In this article we're going to get our hands dirty with the APIs that made this release matter: resource(), rxResource(), httpResource(), and the brand-new debounced() signal utility.
By the end of this article you'll know:
- Why the Resource API finally graduated to stable, and what that means for your day-to-day code
- How to replace a typical HTTP call built with
HttpClientand RxJS withhttpResource() - When to reach for
resource()versusrxResource() - How to debounce a signal properly using
debounced(), without a singlesetTimeoutordebounceTime - How to write unit tests for components built entirely around signals
If you're still writing subscribe() blocks everywhere, stick around — this one's for you.
If this is the kind of practical, no-fluff Angular content you're after, follow along here on Medium so you don't miss the next deep dive.
Before we dive into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular/React versions. Always refer to the official documentation for the most current API and syntax.
Why Angular 22 Is a Bigger Deal Than It Looks
On paper, Angular 22 reads like a routine minor version bump. In practice, it closes out a two-year arc that started with the introduction of signals back in Angular 16. According to the Angular team's own release notes, the two headline features of this version — the Resource API and Signal Forms — have both left experimental status and are now considered production-ready.
That matters because up until now, most teams were forced to mix paradigms: signals for state, RxJS for async data, and @Input/@Output decorators for component boundaries. Angular 22 removes the RxJS requirement for the most common case — fetching data — and lets you stay inside the signal graph from start to finish.
resource(): The Foundation
resource() is the generic building block. You give it a params function that returns whatever value should trigger a refetch, and a loader function that does the actual async work. Whenever the params change, the loader reruns automatically.
import { Component, signal, resource } from '@angular/core';
export class UserProfileComponent {
userId = signal(1);
userResource = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params }) => {
const response = await fetch(`/api/users/${params.id}`);
if (!response.ok) {
throw new Error('Failed to load user');
}
return response.json();
},
});
}
In the template, you get value(), isLoading(), error(), and status() for free — no subscription management required:
@if (userResource.isLoading()) {
<p>Loading user...</p>
} @else if (userResource.error()) {
<p>Something went wrong: {{ userResource.error() }}</p>
} @else {
<p>{{ userResource.value()?.name }}</p>
}
Notice there's no *ngIf here — Angular 22 wants you using the built-in @if/@else control flow, and there's no NgModule in sight because this is a standalone component by default.
rxResource(): For When You Already Have an Observable
Not every data source is fetch-friendly. Maybe you have a WebSocket stream, or a service that already returns observables. That's exactly what rxResource() is for — it lets you plug an existing observable-returning function into the same resource lifecycle.
import { Component, signal, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
export class OrdersComponent {
private http = inject(HttpClient);
customerId = signal('c-1001');
ordersResource = rxResource({
params: () => ({ customerId: this.customerId() }),
stream: ({ params }) =>
this.http.get<Order[]>(`/api/customers/${params.customerId}/orders`),
});
}
Everything downstream — value(), isLoading(), error() — behaves identically to resource(). The only difference is the source: RxJS in, Resource out.
httpResource(): The Shortcut for HTTP Calls
If your async work is "just" an HTTP request, you don't need to write a loader or wire up RxJS interop at all. httpResource() is a purpose-built wrapper around HttpClient that turns any reactive URL or request object into a resource.
import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
export class BookSearchComponent {
isbn = signal<string | null>(null);
book = httpResource<Book>(() => {
const value = this.isbn();
return value ? `/api/books/${value}` : undefined;
});
}
Returning undefined from the reactive function is the idiomatic way to tell httpResource() "don't fetch yet" — handy for search inputs or optional filters. And because it's built on HttpClient, every interceptor you've already registered still applies.
Quick question for you: are you still writing a dedicated service method and a subscribe() call for every single GET request in your app, or have you already started consolidating around resources? Genuinely curious how teams are migrating existing codebases — tell me in the comments.
debounced(): Debouncing Without RxJS
Here's the feature that got the most attention out of this whole release. debounced() takes any signal and a wait time, and gives you back a Resource — not a plain signal — because a debounced value genuinely has loading states while it waits.
import { Component, signal } from '@angular/core';
import { debounced } from '@angular/core';
import { httpResource } from '@angular/common/http';
export class LiveSearchComponent {
query = signal('');
debouncedQuery = debounced(this.query, 300);
results = httpResource<SearchResult[]>(() => {
const term = this.debouncedQuery.value();
return term ? `/api/search?q=${term}` : undefined;
});
onInput(value: string) {
this.query.set(value);
}
}
<input (input)="onInput($any($event.target).value)" placeholder="Search..." />
@if (debouncedQuery.isLoading()) {
<p>Waiting for you to stop typing...</p>
}
@if (results.isLoading()) {
<p>Searching...</p>
} @else {
@for (item of results.value(); track item.id) {
<p>{{ item.title }}</p>
}
}
Compare that to the RxJS version most of us have written a dozen times, with a FormControl, debounceTime, distinctUntilChanged, and a manual switchMap. The signal version reads top to bottom, has no subscription to clean up, and exposes its own isLoading() so you can show a "still waiting" state distinct from the actual network request.
Unit Testing Signal-Based Components
The good news: testing resource-driven components is arguably simpler than testing RxJS-based ones, because there's no need to fake observables or flush subjects manually. Here's a test for the LiveSearchComponent above using Angular's TestBed and fakeAsync.
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
provideHttpClientTesting,
HttpTestingController,
} from '@angular/common/http/testing';
import { LiveSearchComponent } from './live-search.component';
describe('LiveSearchComponent', () => {
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [LiveSearchComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
});
httpMock = TestBed.inject(HttpTestingController);
});
it('waits for the debounce window before firing a request', fakeAsync(() => {
const fixture = TestBed.createComponent(LiveSearchComponent);
const component = fixture.componentInstance;
component.onInput('angular');
fixture.detectChanges();
// No request yet, still inside the debounce window
httpMock.expectNone('/api/search?q=angular');
tick(300);
fixture.detectChanges();
const req = httpMock.expectOne('/api/search?q=angular');
req.flush([{ id: 1, title: 'Angular 22 Guide' }]);
fixture.detectChanges();
expect(component.results.value()?.length).toBe(1);
}));
afterEach(() => {
httpMock.verify();
});
});
The key trick here is tick(300) — it advances the virtual clock past the debounce window before we assert the request went out. If you skip that tick, expectOne will fail because debounced() genuinely hasn't settled yet, which is exactly the behavior you want to verify.
If that debounce test just saved you from writing a flaky, timing-dependent spec by hand, this is a good moment to hit that clap button so more people find this walkthrough.
Bonus Tips
- Reach for
resource()when your data source doesn't naturally fit "URL in, JSON out" — think file reads, IndexedDB, or custom async work. - Use
httpResource()as your default for anything backed by a REST endpoint; it's the least code for the most common case. - Combine
debounced()withhttpResource()'s "returnundefinedto skip" pattern for the cleanest possible search-as-you-type implementation. - Resources destroy themselves automatically when their owning component or service is destroyed, so you generally don't need manual cleanup — but if you create one outside a component context, pass an explicit
injectoroption. - When migrating an existing RxJS-heavy service, start with
rxResource()rather than rewriting everything at once. It lets you keep your existing observable-returning methods while adopting the resource lifecycle in your templates.
Recap
Angular 22 is the release where the two-year signals migration actually finishes. resource(), rxResource(), and httpResource() are stable, which means the "wait for it to leave experimental" excuse for avoiding them is gone. debounced() gives you a clean, RxJS-free way to handle the classic search-input problem, complete with its own loading state. And because everything is built on signals, testing gets simpler, not harder — fakeAsync and tick are all you need.
What did you think?
Did this approach match how you are solving it, or do you have a different take? Drop a comment — I genuinely read every single one.
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let us connect — find me on LinkedIn or GitHub and let us keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)