Angular’s Resource API + injectAsync: Async State Without the Boilerplate
A practical, beginner-friendly walkthrough of the two APIs Angular shipped to kill two very different flavors of async boilerplate — fetching data, and loading the code that fetches it.
If you’ve built anything non-trivial in Angular, you’ve written this pattern more times than you’d like to admit: a loading flag, an error variable, a data variable, a subscription you have to remember to clean up, and an effect() or constructor block wiring it all together. It works, but it's the same fifteen lines copy-pasted across every component that touches an API.
Angular’s Resource API (resource(), rxResource(), httpResource()) and the newer injectAsync() function exist to delete that boilerplate. They solve two different problems that both happen to be "async," so this article covers both — what each one is for, how it works under the hood, and where the line between them sits.
A quick honest note before we start: despite what the title suggests,
resource()andinjectAsync()are not two flavors of the same tool.resource()is about async data — fetching something and reacting to it.injectAsync()is about async dependencies — deferring when a service's code even reaches the browser. They live in the same "async state" family, but they solve genuinely different problems. I'll draw that line clearly in Part 3, because conflating them is a common source of confusion once you start reading Angular docs.
Both APIs reached stable status in Angular v22 (resource() started experimental back in v19 and matured over several releases; injectAsync() is newer and stable from the moment it landed). Everything below reflects the current stable API surface.
Part 0: A 60-Second Signals Refresher
If you’re already comfortable with signal(), computed(), and effect(), skip to Part 1. If not, here's the minimum you need:
import { signal, computed, effect } from '@angular/core';
// A signal is a reactive box holding a value
const count = signal(0);
// Read it by calling it like a function
console.log(count()); // 0
// Update it
count.set(5);
count.update(v => v + 1);
// computed() derives a new signal from others — it recalculates
// automatically whenever a signal it reads changes
const doubled = computed(() => count() * 2);
// effect() runs a side effect whenever a signal it reads changes
effect(() => {
console.log('count is now', count());
});The important habit: any function that calls a signal automatically “subscribes” to it. No .subscribe(), no manual wiring. That's the foundation everything else in this article builds on.
Part 1: The Problem — Async Data the Old Way
Say you need to show a user’s profile, and the user ID itself is reactive (it changes when the person navigates, or picks someone from a list). Before the Resource API, you’d write something like this:
@Component({ /* ... */ })
export class UserProfileComponent {
private http = inject(HttpClient);
userId = signal(1);
user = signal<User | null>(null);
loading = signal(false);
error = signal<Error | null>(null);
constructor() {
effect(() => {
const id = this.userId();
this.loading.set(true);
this.error.set(null);
this.http.get<User>(`/api/users/${id}`).subscribe({
next: (user) => {
this.user.set(user);
this.loading.set(false);
},
error: (err) => {
this.error.set(err);
this.loading.set(false);
},
});
});
}
}This works, but count the responsibilities you’ve taken on by hand:
- Three separate signals to represent one logical concept (“the state of fetching a user”)
- No request cancellation — if
userIdchanges again while a request is in flight, the old response can still land and overwrite newer data (a classic race-condition bug) - Manual loading/error bookkeeping on every single branch
effect()is technically meant for side effects, not for deriving data — using it here is a bit of a misuse of the primitive
Multiply this by every component that fetches something, and you’ve got a lot of near-identical, easy-to-get-subtly-wrong code.
Part 2: Enter the Resource API
A Resource is Angular’s answer: a single reactive container that bundles together what to fetch, how to fetch it, and the current state of that fetch — all exposed as signals.
The Shape of a Resource
Every resource is built from two pieces:
params— a reactive function describing what to fetch. Whenever the signals it reads change, the resource knows a new fetch is needed.loader— an async function describing how to fetch it, given the current params.
import { resource, signal } from '@angular/core';
@Component({ /* ... */ })
export class UserProfileComponent {
userId = signal(1);
userResource = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/users/${params.id}`, {
signal: abortSignal, // wired up for you automatically
});
if (!response.ok) throw new Error('Failed to load user');
return response.json() as Promise<User>;
},
});
}That’s the entire replacement for the 20-line version above. When userId changes, the resource automatically:
- Cancels the in-flight request via the
abortSignalit hands your loader - Fires a new request with the updated params
- Tracks loading/error/success state for you, exposed as signals you read in the template
Beginner note on naming: if you find older tutorials (written around Angular 19–20, when this was experimental) using a property called
requestinstead ofparams, that's not a typo — Angular renamed it as the API matured toward stability.paramsis the current, stable name.
The Resource Lifecycle
Under the hood, every resource is always in one of six states, represented by ResourceStatus:
The diagram is worth sitting with for a second: notice that reloading isn't a dead end — it loops back into loading every time the signal changes again. That's the whole point of a resource: it's not "fetch once," it's "stay in sync with this signal, forever, until the component is destroyed."
Reading Resource State in Your Template
A ResourceRef exposes everything you need as plain signals and methods:
value() // Signal<T | undefined> — the current data (or defaultValue)
status() // Signal<ResourceStatus> — the current lifecycle state
error() // Signal<unknown> — set only when status is 'error'
isLoading() // Signal<boolean> — true during 'loading' or 'reloading'
hasValue() // boolean — type-narrowing helper
reload() // manually re-run the loader with the current params
set(value) // manually overwrite the value (moves status to 'local')
update(fn) // update the value based on its previous value
asReadonly() // strips the writable methods, for exposing to children
destroy() // stops the resource and cancels any pending requestIn a template:
@if (userResource.isLoading()) {
<app-spinner />
} @else if (userResource.error()) {
<p class="error">Couldn't load this user. {{ userResource.error() }}</p>
} @else if (userResource.hasValue()) {
<h2>{{ userResource.value().name }}</h2>
<p>{{ userResource.value().email }}</p>
}
<button (click)="userResource.reload()">Refresh</button>Notice there’s no async pipe and no manual subscription cleanup — signals just work with Angular's change detection natively, and the resource cleans itself up when the component is destroyed.
defaultValue: Avoiding undefined Everywhere
By default, value() is typed T | undefined (because before the first load, there's nothing there yet). If you'd rather never deal with undefined, give it a fallback:
userResource = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params }) => fetchUser(params.id),
defaultValue: { id: 0, name: 'Loading…', email: '' },
});
// value() is now typed as User, never User | undefinedA Critical Rule: Resources Are for Reading, Not Mutating
This trips people up, so it’s worth calling out explicitly. The official docs are blunt about it: resource() is intended for read operations, not mutations. Why? Because a resource will cancel an in-flight loader whenever its params change or the resource is destroyed — and if your loader was in the middle of a POST or DELETE, cancelling it prematurely could leave your backend in an inconsistent state. Keep mutations in a regular method call or a dedicated service, and reserve resources for GET-shaped, cancellable, "give me the current state of X" operations.
Part 3: rxResource() — When You Already Have an Observable
If your codebase still leans on RxJS (interceptors, complex operator chains, existing services that return Observable), you don't have to rewrite everything to fetch(). rxResource() is the same lifecycle, but the loader returns an Observable instead of a Promise:
import { rxResource } from '@angular/core/rxjs-interop';
@Component({ /* ... */ })
export class UserProfileComponent {
private http = inject(HttpClient);
userId = signal(1);
userResource = rxResource({
params: () => ({ id: this.userId() }),
loader: ({ params }) => this.http.get<User>(`/api/users/${params.id}`),
});
}Same value(), status(), error(), isLoading() — just swap the loader's return type. This is the easiest on-ramp if you're migrating an existing service-based codebase incrementally rather than all at once.
Part 4: httpResource() — The HTTP-Flavored Shortcut
For the extremely common case of “I just need to hit an HTTP endpoint,” httpResource() (from @angular/common/http) cuts even more ceremony. It's built directly on top of HttpClient, so you keep interceptors, auth headers, and everything else your app already relies on.
Get Vasanthan K’s stories in your inbox
Join Medium for free to get updates from this writer.
typescript
import { httpResource } from '@angular/common/http';
@Component({ /* ... */ })
export class ProductListComponent {
category = signal('electronics');
products = httpResource<Product[]>(() => ({
url: '/api/products',
params: { category: this.category() }, // becomes ?category=electronics
}));
}For the simplest case — a static or reactively-computed URL — you can skip the object entirely:
products = httpResource<Product[]>(() => `/api/products?category=${this.category()}`);httpResource gives you everything resource() does, plus HTTP-specific signals:
products.statusCode() // Signal<number | undefined> — the HTTP status code
products.headers() // Signal<HttpHeaders | undefined>
products.progress() // Signal<HttpProgressEvent | undefined> — for download progressAnd if you’re not expecting JSON, there are typed variants for other response shapes:
httpResource.text('/api/readme.txt');
httpResource.blob('/api/export.zip');
httpResource.arrayBuffer('/api/file.bin');One important caveat carried over from resource(): httpResource is for reads. It's not meant to replace every use of HttpClient — keep POST/PUT/DELETE calls in a normal service method.
Part 5: A Realistic Example — Debounced Search-As-You-Type
Let’s put this together into something you’d actually ship: a search box that queries an API as the user types, without spamming the backend on every keystroke.
The naive version fires a request on every character. The old fix was pulling in RxJS’s debounceTime and converting signals to observables and back. Angular now has a signal-native primitive for exactly this: debounced()
import { signal, resource, debounced } from '@angular/core';
@Component({
template: `
<input (input)="query.set($event.target.value)" placeholder="Search…" />
@if (results.isLoading()) {
<p>Searching…</p>
} @else if (results.error()) {
<p class="error">Something went wrong.</p>
}
<ul>
@for (item of results.value(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`,
})
export class SearchComponent {
query = signal('');
// waits 300ms after the user stops typing before the debounced
// signal actually updates
debouncedQuery = debounced(this.query, 300);
results = resource({
params: () => this.debouncedQuery.value(),
loader: async ({ params }) => {
if (!params) return [];
const res = await fetch(`/api/search?q=${encodeURIComponent(params)}`);
return res.json();
},
defaultValue: [],
});
}A few things worth understanding here, since this is where beginners usually get confused:
debounced()itself returns a Resource (yes, really — debouncing is implemented as a resource internally). While the debounce timer is counting down, itsstatus()is'loading'and itsvalue()still returns the previous settled value — so your UI doesn't flash empty.- Chaining
resource()off ofdebouncedQuery.value()means the searchresourceonly re-fires once the debounce window has actually elapsed — no manual timers, noswitchMap, no observable conversion. - This entire “type → wait → cancel-in-flight → fetch → render” pipeline is about a dozen lines, split across two clearly-named signals.
Part 6: The Other Async Problem — Loading Code, Not Data
Everything above solves “how do I get data into my component without boilerplate.” There’s a second, completely different async pain point Angular apps run into: bundle size.
Say you have a MarkdownService that pulls in a syntax highlighter and a markdown parser — genuinely useful, but only needed if the user opens a specific editor panel. If you inject() it normally:
import { MarkdownService } from './markdown.service'; // static import
@Component({ /* ... */ })
export class PostEditorComponent {
private markdown = inject(MarkdownService); // pulled in eagerly
}…that service’s code — and everything it imports — ships in your initial bundle, even for users who never open the markdown panel. Every user pays the download cost for a feature most of them won’t touch in that session.
Enter injectAsync()
injectAsync() lets you defer when a service's code is even requested from the network, without giving up dependency injection:
import { injectAsync } from '@angular/core';
@Component({ /* ... */ })
export class PostEditorComponent {
private markdown = injectAsync(() =>
import('./markdown.service').then(m => m.MarkdownService)
);
async onOpenPreview() {
const svc = await this.markdown();
this.previewHtml.set(svc.render(this.rawText()));
}
}injectAsync() returns a function, not the service directly — calling that function returns a Promise that resolves to the actual instance, fully constructed by Angular's DI system (so its own dependencies get injected normally too). Until onOpenPreview() actually runs, the browser has never even requested markdown.service.js.
One Requirement to Remember
For the lazy-loading to actually work, the service has to be auto-provided — decorated with @Injectable({ providedIn: 'root' }) (the classic way) or the newer @Service() decorator. If a service is only registered in a component's providers array, injectAsync() has no way to look it up lazily, since there's nothing to auto-resolve.
@Injectable({ providedIn: 'root' })
export class MarkdownService { /* ... */ }Prefetching: Getting the Best of Both Worlds
Waiting for the network after the user clicks can feel laggy for a service that’s likely to be needed soon. injectAsync() supports a prefetch option to start the download earlier, without blocking the initial render:
private markdown = injectAsync(
() => import('./markdown.service').then(m => m.MarkdownService),
{ prefetch: onIdle } // starts downloading once the browser is idle
);With prefetch: onIdle, the chunk quietly downloads in the background once the main thread has nothing more urgent to do — so by the time the user actually clicks "Preview," the promise often resolves near-instantly, because the code is already sitting in the browser's cache.
Part 7: resource() vs. injectAsync() — When to Reach for Which
Since both get lumped under “Angular’s new async tools,” here’s the honest decision rule:
They’re not competing tools — they’re complementary, and it’s completely normal to use both in the same component:
@Component({ /* ... */ })
export class ReportComponent {
reportId = signal<string>('');
// Data: stays in sync with reportId automatically
report = resource({
params: () => this.reportId(),
loader: ({ params }) => fetch(`/api/reports/${params}`).then(r => r.json()),
});
// Dependency: only downloaded when the user actually wants to export
private pdfExporter = injectAsync(() =>
import('./pdf-exporter.service').then(m => m.PdfExporterService)
);
async onExportPdf() {
const exporter = await this.pdfExporter();
exporter.generate(this.report.value());
}
}The report data loads reactively the moment reportId is known. The (probably heavy) PDF-generation library never touches the network until someone actually clicks "Export."
Common Pitfalls to Avoid
A short list worth keeping in mind as you adopt these:
- Don’t use
resource()for mutations. Save, delete, and update operations belong in a regular method — resources can cancel in-flight loaders out from under you. - Remember the
params→ notrequestrename if you're reading tutorials written during the experimental phase (Angular 19–21). injectAsync()needsprovidedIn: 'root'or@Service(). A component-scoped provider can't be resolved lazily.- A resource with no
paramsfunction only fires once, on creation, and only re-runs if you call.reload()manually — that's intentional, for data that genuinely doesn't depend on anything reactive. isLoading()istrueduring bothloadingandreloading. If you specifically want to distinguish "first load" from "refreshing existing data" (say, to avoid flashing a full-page spinner on a refresh), checkstatus()directly instead.
Wrapping Up
Angular’s Resource API and injectAsync() both chip away at the same underlying frustration — that async work in a UI framework has historically meant hand-rolling the same loading/error/cancellation dance over and over. They just attack it from two different angles: one keeps your data in sync with minimal ceremony, the other keeps your bundle lean by deciding, deliberately, when code is even allowed to show up.
If you’re starting a new Angular project today, or maintaining one on v22+, there’s very little reason to reach for the old manual-signal-plus-subscribe pattern for new data-fetching code — and if you’ve got a component quietly shipping a heavy dependency nobody asked for, injectAsync() is a small, mechanical refactor with an immediately measurable bundle-size win.








