A resource turns an asynchronous operation (usually an HTTP request) into a set of signals: value(), status(), error(), isLoading(). You give it reactive parameters; when they change, the resource reloads by itself and automatically cancels the previous request. From v22, resource, rxResource and httpResource are stable.
- The
resource,rxResourceandhttpResourceAPIs have left experimental. - You can throw
ResourceParamsStatus.IDLE/LOADINGfromparams. paramsreceives a context withchain()for dependent resources.- Streams can have synchronous values too; resources can be cached during SSR.
The three variants
| Function | Your loader returns | When to pick it |
|---|---|---|
httpResource() | — (it makes the request itself through HttpClient) | The most common case: a simple GET. Goes through interceptors. |
resource() | Promise | Any async work: fetch, IndexedDB, SDKs, Web Workers. |
rxResource() | Observable | You already have RxJS services or streams (websocket). |
resource(): anatomy
import { Component, resource, signal } from '@angular/core';
@Component({ /* ... */ })
export class UserProfile {
readonly userId = signal<number | undefined>(1);
readonly user = resource({
// 1. params: a reactive function. When the result changes → it reloads.
// If it returns undefined → status 'idle', the loader does NOT run.
params: () => this.userId(),
// 2. loader: receives params + an AbortSignal (the old request is cancelled automatically)
loader: async ({ params: id, abortSignal }) => {
const res = await fetch(`/api/users/${id}`, { signal: abortSignal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as User;
},
});
}- ✓ resolved #1
- → start #1
- Press #2, #3, #4 quickly — the old requests are cancelled.
The full API of a resource
user.value(); // Signal<T | undefined> — the last value (or defaultValue)
user.hasValue(); // type guard: true if value() isn’t undefined
user.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local'
user.isLoading(); // true for 'loading' and 'reloading'
user.error(); // Error | undefined
user.reload(); // reloads with the same params (status 'reloading', value stays)
// Local write (e.g. an optimistic update) → status becomes 'local'
user.set(newUser);
user.update((u) => ({ ...u!, name: 'New' }));
user.asReadonly(); // exposed without set/update
user.snapshot(); // v21.2: { status, value | error } in a single signalhttpResource(): reactive GETs in two lines
httpResource uses HttpClient underneath, so your interceptors work (auth, logging, mock). You give it a function that returns the URL or a request object; every signal read there becomes a dependency.
import { httpResource } from '@angular/common/http';
export class UserSearch {
readonly query = signal('');
readonly role = signal<Role | ''>('');
// Simple form: a function that returns the URL (undefined → idle)
readonly user = httpResource<UserDto>(() => `/api/users/${this.userId()}`);
// Full form: a request object + options
readonly users = httpResource<PageDto<UserDto>>(
() => ({
url: '/api/users',
params: { q: this.query(), ...(this.role() ? { role: this.role() } : {}) },
headers: { 'X-Client': 'atlas' },
}),
{ defaultValue: { items: [], total: 0 } }, // value() is never undefined anymore
);
}
// Other formats:
httpResource.text(() => '/api/readme');
httpResource.blob(() => `/api/files/${id()}`);
httpResource.arrayBuffer(() => '/api/binary');- Ana Popescu· Cluj-Napocaadmin
- Mihai Ionescu· Bucureștieditor
- Ioana Dumitru· Iașiviewer
- Radu Stan· Timișoaraeditor
- Elena Marin· Brașovviewer
- Andrei Georgescu· Cluj-Napocaadmin
- Maria Constantin· Constanțaviewer
- Vlad Petrescu· Sibiueditor
- Cristina Pop· Oradeaviewer
- Bogdan Rusu· Iașiviewer
Validation and mapping with parse
The parse option runs on the response before it reaches value(). It’s the perfect place for a validation schema (Zod, Valibot) or for mapping a DTO → model (see Interfaces & models).
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.email() });
// `parse` validates/transforms the response — the error ends up in user.error()
readonly user = httpResource(() => `/api/users/${this.id()}`, {
parse: UserSchema.parse,
});
// or mapping a DTO → domain model
readonly product = httpResource(() => `/api/products/${this.id()}`, {
parse: (dto: ProductDto) => toProduct(dto),
});rxResource(): when you already have Observables
import { rxResource } from '@angular/core/rxjs-interop';
export class Orders {
private readonly api = inject(OrdersApi); // returns Observables
readonly status = signal<OrderStatus>('open');
readonly orders = rxResource({
params: () => ({ status: this.status() }),
stream: ({ params }) => this.api.list(params.status), // Observable<Order[]>
defaultValue: [],
});
}
// When params change, the old subscription is closed (like switchMap).
// A stream can emit many times (e.g. a websocket) — value() updates on every emission.Special statuses and chained resources v22
import { ResourceParamsStatus, resource } from '@angular/core';
// v22: in params you can “throw” a special status
readonly report = resource({
params: () => {
const range = this.range();
if (!range) throw ResourceParamsStatus.IDLE; // nothing to load yet
if (!this.permissions.hasValue()) throw ResourceParamsStatus.LOADING; // waiting for something else
return range;
},
loader: ({ params }) => this.api.report(params),
});
// Chaining resources with ctx.chain(): orders waits for user
readonly user = httpResource<User>(() => '/api/me');
readonly orders = httpResource<Order[]>((ctx) => {
const user = ctx.chain(this.user);
return user ? `/api/users/${user.id}/orders` : undefined;
});
// while user is loading → orders is 'loading'; if user errors → orders errors toouserresolvedAna Popescu · Cluj-Napoca
sameCityresolvedAna Popescu, Andrei Georgescu
readonly user = httpResource<User>(() =>
this.userId() ? `/api/users/${this.userId()}` : undefined,
);
readonly sameCity = httpResource<Page<User>>((ctx) => {
const user = ctx.chain(this.user);
return user ? `/api/users?q=${user.city}` : undefined;
});Statuses, as an effect sees them
- 3 ms user: loading · sameCity: loading
- 698 ms user: resolved · sameCity: loading
- 1470 ms user: resolved · sameCity: resolved
Writes: resource is not for POST
Resources are designed for reading. A resource re-runs its loader when its parameters change — exactly what you DON’T want for a “save” command. For mutations use HttpClient and then update the resource locally (update) or reload it (reload).
// resource / httpResource are for READING (GET).
// For writes (POST/PUT/DELETE) use HttpClient directly, then update the resource.
export class TodoStore {
private readonly http = inject(HttpClient);
readonly todos = httpResource<Todo[]>(() => '/api/todos', { defaultValue: [] });
async add(title: string) {
const created = await firstValueFrom(this.http.post<Todo>('/api/todos', { title }));
this.todos.update((list) => [...list, created]); // local update, no refetch
}
async remove(id: number) {
this.todos.update((list) => list.filter((t) => t.id !== id)); // optimistic
try {
await firstValueFrom(this.http.delete(`/api/todos/${id}`));
} catch {
this.todos.reload(); // simple rollback: reload from the server
}
}
}When YES, when NO
- Data that depends on state: details by
id, lists by filters, pagination. - Any screen with “loading / error / data”.
- A replacement for
toSignal(this.route.params.pipe(switchMap(...))). - Signal stores that need data from the server.
- POST / PUT / DELETE →
HttpClient. - Complex flows with retry/backoff, polling, stream combinations → RxJS directly (or
rxResourceon top of it). - Static data, loaded once at startup →
provideAppInitializeror a resolver.
Common pitfalls
value()isundefinedat the start and while loading for the first time. UsehasValue()ordefaultValue.- “Swallowed” errors: if you don’t display
error(), the user only sees an empty list. Handle the error state explicitly. - Reading
value()when the status iserrorthrows the error — checkerror()orhasValue()first. - Params that change on every keystroke: each keystroke = one request (the old one is cancelled). For search, combine with debounced().
- New objects in params:
params: () => ({ id: this.id() })is fine — the resource compares the result and reloads only when the signals read change.
Cheat sheet
resource({ params: () => p(), loader: async ({ params, abortSignal, previous }) => ... });
rxResource({ params: () => p(), stream: ({ params }) => obs$ });
httpResource<T>(() => url | { url, method, params, headers, body } | undefined, { defaultValue, parse });
r.value() r.hasValue() r.status() r.isLoading() r.error() r.reload() r.set() r.update()
params → undefined ⇒ 'idle'
params → throw ResourceParamsStatus.IDLE / LOADING (v22)
(ctx) => ctx.chain(other) ⇒ chaining (v22)