Angular Resource API Is Finally Stable in Angular 22
All articles
AngularSignals

Angular Resource API Is Finally Stable in Angular 22

July 30, 20268 min read

Angular 22 just landed, and the change I'm most excited about isn't a flashy new feature — it's one going stable. The Resource API, which showed up as a developer preview back in Angular 19, is now production-ready. resource, rxResource, and httpResource are all stable as of this release, and I've been waiting for this since the day I saw the first preview.

Here's why it matters to me: it finally lets you build an app that's fully signal-reactive, from your components down to your HTTP calls, without gluing two reactivity systems together.

The world before signals had HTTP

When I started with Angular, HttpClient only worked if you imported HttpClientModule somewhere, usually in AppModule:

// app.module.ts
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  imports: [HttpClientModule],
  // ...
})
export class AppModule {}

Once standalone components and bootstrapApplication showed up in Angular 14, and became the default direction from 16 onward, the module import got replaced by a provider function in your app config:

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [provideHttpClient()],
});

That was basically the only change that mattered for HTTP. Services stayed exactly the same: inject HttpClient, call .get(), get back an Observable.

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  getProduct(id: string): Observable<Product> {
    return this.http.get<Product>(`/api/products/${id}`);
  }
}

And in the component, you'd subscribe to it, and manually track a loading flag yourself:

export class ProductDetailComponent implements OnInit {
  product?: Product;
  isLoading = false;
  errorMessage?: string;

  private productService = inject(ProductService);

  ngOnInit() {
    this.isLoading = true;
    this.productService.getProduct(this.productId).subscribe({
      next: (product) => {
        this.product = product;
        this.isLoading = false;
      },
      error: (err) => {
        this.errorMessage = err.message;
        this.isLoading = false;
      },
    });
  }
}

Nothing wrong with this, on its own. The problem showed up once signals entered the picture.

The hybrid problem

Once Angular pushed signals for component state and template inputs, most apps ended up with a split personality: signals everywhere in the component layer and business logic, observables everywhere in the service layer, and toSignal() sitting at the boundary converting one into the other. You'd write your component logic in signals, then hit a network call and have to drop back into RxJS operators, then convert the result back to a signal to use it in a template or a computed. It works, but it's two mental models stitched together, and you feel the seam every time you cross it.

What I wanted was simple: make an HTTP request and get a signal back, directly, no conversion step. That's exactly what resource gives you.

Fetching data as a signal

export class ProductDetailComponent {
  productId = input.required<string>();

  productResource = resource({
    params: () => ({ id: this.productId() }),
    loader: ({ params }) =>
      fetch(`/api/products/${params.id}`).then((res) => res.json() as Promise<Product>),
  });
}

params is a reactive computation — same idea as computed — that re-runs whenever a signal it reads changes. Every time it produces a new value, the loader fires with that value and returns a promise. The result lands directly on productResource.value() as a signal. No subject, no subscription, no toSignal().

In the template:

@if (productResource.isLoading()) {
  <app-skeleton />
} @else if (productResource.error()) {
  <p>Something went wrong loading this product.</p>
} @else if (productResource.hasValue()) {
  <app-product-card [product]="productResource.value()" />
}

No more manually tracked isLoading

Notice there's no isLoading = false field anywhere in that component, and no code setting it to true before the call and false in next/error/complete. That was boilerplate I wrote in basically every service-consuming component for years. With resource, loading state is built in — isLoading() is just a signal on the resource itself.

It goes further than a boolean, too. Every resource exposes a status() signal with one of these values:

type ResourceStatus = 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local';

idle means there's no valid request yet, so the loader hasn't run. loading means the loader is running because params changed. reloading means it's running because you called .reload() — and it keeps the previous value visible while it does. resolved is a completed, successful load. error is a failed one. local shows up if you set the resource's value yourself with .set() or .update(), bypassing the loader entirely. That last one is handy for optimistic updates.

abortSignal: basically a CancellationToken

If you've written any .NET async code, abortSignal will feel familiar — it's the same idea as passing a CancellationToken into a method and letting the runtime tell you when to stop.

searchResource = resource({
  params: () => ({ query: this.searchTerm() }),
  loader: ({ params, abortSignal }) =>
    fetch(`/api/search?q=${params.query}`, { signal: abortSignal }).then((res) => res.json()),
});

If searchTerm changes again before the current request finishes, Angular aborts the in-flight loader by triggering abortSignal, and fetch cancels the request. You don't write any cancellation logic yourself — you just wire the signal into whatever API supports it. Compare that to the old pattern of manually unsubscribing or relying on switchMap to cancel a previous observable for you; this is the same cancellation guarantee, just explicit and built into the loader contract.

Reloading on demand

Sometimes you don't want to wait for params to change — you just want to re-run the request, like a refresh button.

export class OrdersListComponent {
  ordersResource = resource({
    loader: () => fetchOrders(),
  });

  refresh() {
    this.ordersResource.reload();
  }
}

reload() re-runs the loader with the current params. The resource status flips to reloading, and — unlike a fresh loading state — it keeps showing the last resolved value while the new one comes in, so your UI doesn't flash empty on every refresh.

loader vs. stream: fetch once vs. fetch continuously

loader is for the common case — a request that resolves once per param change, like a REST call. If there's no reactive params function, it just runs a single time, on creation, unless you call reload() yourself:

appConfigResource = resource({
  loader: () => fetch('/api/config').then((res) => res.json() as Promise<AppConfig>),
});

That's it — no params to react to, so this fires once when the resource is created and sits there until you explicitly reload it. Good fit for things like feature flags or app config that don't change per request.

stream is for sources that push more than one value over time — WebSockets, Server-Sent Events, Firestore listeners. Instead of returning a promise, stream returns a signal, and the resource just tracks whatever that signal currently holds.

Streaming resources replacing BehaviorSubject

This is the one that actually excites me the most. I've used BehaviorSubject constantly for exactly this pattern: a live notification count, a stats widget updated from multiple places, some piece of state that several components need to see update in real time. It always meant a service holding a subject, components subscribing (or using the async pipe), and being careful about unsubscribing.

@Injectable({ providedIn: 'root' })
export class NotificationsService {
  notificationsResource = resource({
    stream: () => {
      const state = signal<{ value: Notification[] } | { error: unknown }>({ value: [] });
      const eventSource = new EventSource('/api/notifications/stream');

      eventSource.onmessage = (event) => {
        const notifications = JSON.parse(event.data) as Notification[];
        state.set({ value: notifications });
      };

      eventSource.onerror = (err) => {
        state.set({ error: err });
      };

      return state;
    },
  });
}

The signal returned from stream carries either { value } or { error }, and the resource reflects whichever one it receives, updating value(), error(), and status() accordingly. Inject this service anywhere in the app and you get the same live, signal-based state everywhere, with no manual subscription management and no risk of a forgotten unsubscribe(). For the kind of cross-app shared state I used to reach for BehaviorSubject for, this is a direct, cleaner replacement.

Chaining resources instead of switchMap

The other pattern I used constantly with RxJS was chaining dependent requests with switchMap — fetch a product, then fetch its reviews based on the product's category:

product$ = this.route.params.pipe(
  switchMap((params) => this.productService.getProduct(params['id'])),
);

reviews$ = this.product$.pipe(
  switchMap((product) => this.reviewService.getReviewsByCategory(product.category)),
);

With resources, this becomes an explicit chain, available inside the params function:

const productResource = resource({
  params: () => ({ id: productId() }),
  loader: ({ params }) => fetchProduct(params.id),
});

const reviewsResource = resource({
  params: ({ chain }) => chain(productResource)?.category,
  loader: ({ params: category }) => fetchReviewsByCategory(category),
});

chain(productResource) doesn't just grab the current value — it propagates status too. While productResource is loading, reviewsResource also goes into loading and its loader doesn't run. If productResource errors, reviewsResource reflects that error state instead of trying to run with stale or undefined params. Once productResource resolves, chain returns its value and reviewsResource's loader finally fires. You get the same dependent-request behavior switchMap gave you, but the intermediate states — loading, error — are explicit and correct by default instead of something you have to model yourself.

Wrapping up

None of these pieces are individually shocking if you've followed Angular's signals work over the last couple of releases. What's different now is that they're stable, so I don't have to caveat every use of resource with "well, it might change." An app can genuinely be signal-first end to end now — components, business logic, and data fetching all speaking the same reactive language, no toSignal() bridge required. If you've been holding off on adopting it because it was experimental, Angular 22 is the point where that excuse is gone.