Resource API Mastery: How resource() Changes the Architecture of Data Fetching in Modern Angular
The biggest benefit of
resource()isn't writing less code — it's making asynchronous state predictable.
Table of Contents
- Introduction: The Async State Problem
- The Traditional Pattern: What We've Been Doing
- The Real Cost of Scattered State
- Meet
resource(): Angular's Signal-Native Async Primitive - How
resource()Works Under the Hood - The Automatic Lifecycle: Cancel, Reload, Update
- Developer Experience: What Changes in Your Day-to-Day
-
Architecture Patterns with
resource()- Pattern 1: Basic CRUD with HttpClient
- Pattern 2: Parameterized Resources with Multiple Dependencies
- Pattern 3: Derived State with
computed() - Pattern 4: Side Effects with
effect() - Pattern 5: Service Integration
- Pattern 6: SSR and Hydration
- Pattern 7: Error Boundaries and Retry
- Pattern 8: Optimistic Updates
- Template Patterns: Loading, Error, and Success UI
- When NOT to Use the Resource API
- Where Each Tool Fits: HttpClient, RxJS, and Resource API
- Testing Strategies
- Performance Considerations
- Migration Strategy: Moving from Traditional Patterns
- Best Practices Summary
- Common Mistakes to Avoid
- Enterprise Considerations
- Conclusion: The Architecture Mindset Shift
Introduction: The Async State Problem
If you've built Angular applications at scale, you've written this code hundreds of times:
export class SomeComponent {
loading = false;
error: string | null = null;
data: SomeData | null = null;
ngOnInit() {
this.loading = true;
this.http.get('/api/data').subscribe({
next: (res) => { this.data = res; this.loading = false; },
error: (err) => { this.error = err.message; this.loading = false; }
});
}
}
It works. It's familiar. And it's quietly one of the most expensive patterns in your codebase.
Not because it's buggy — because it's scattered state. Loading, error, data, and cleanup logic are strewn across properties, lifecycle hooks, subscription handlers, and template branches. Every component reinvents the same wheel. Every team develops slightly different conventions. And every new developer has to learn "how we do async here" before they can ship a feature.
In modern Angular applications, the Resource API (resource()) addresses this at the architectural level. It doesn't just reduce boilerplate — it changes how we think about asynchronous data by treating it as first-class reactive state within Angular's Signals ecosystem.
This post is not an introduction to the Resource API. It's an architecture deep-dive for senior Angular developers, software architects, and enterprise teams who want to understand why it exists, when to use it, and how it changes component design.
The Traditional Pattern: What We've Been Doing
Let's be precise about what we're replacing. The traditional Angular async pattern typically looks like this:
import { Component, inject, OnInit, DestroyRef } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
interface User {
id: number;
name: string;
email: string;
role: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@if (loading) {
<div class="skeleton-loader">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
</div>
} @else if (error) {
<div class="error-banner">
<p>{{ error }}</p>
<button (click)="loadUser()">Retry</button>
</div>
} @else if (user) {
<div class="user-card">
<img [src]="user.avatar" [alt]="user.name" />
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<span class="badge">{{ user.role }}</span>
</div>
}
`
})
export class UserProfileComponent implements OnInit {
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
// State properties — scattered and imperative
loading = false;
error: string | null = null;
user: User | null = null;
// We need a userId, but if it changes, we need to reload
userId = 1;
ngOnInit(): void {
this.loadUser();
}
loadUser(): void {
this.loading = true;
this.error = null;
this.http.get<User>(`/api/users/${this.userId}`)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (user) => {
this.user = user;
this.loading = false;
},
error: (err) => {
this.error = err.message ?? 'Failed to load user';
this.loading = false;
}
});
}
refresh(): void {
this.loadUser();
}
}
This pattern has several characteristics:
- Imperative state management — We manually set and reset flags.
- Scattered logic — State updates happen in subscription handlers, far from where state is declared.
-
Manual cleanup —
takeUntilDestroyedorngOnDestroysubscriptions are required. -
No automatic reload — If
userIdchanges, we need explicit logic to trigger a new fetch. - No cancellation — If the component is destroyed mid-request, the subscription may leak or the callback may fire on a destroyed view.
- Template complexity — The template must know about three separate properties to render correctly.
Multiply this by 200 components in an enterprise application, and you have a consistency problem.
The Real Cost of Scattered State
Before we look at the solution, let's understand the organizational cost of the traditional pattern:
1. Duplicated Boilerplate
Every component that fetches data needs loading, error, data, subscription handling, and cleanup. In a large application, this is thousands of lines of identical infrastructure code.
2. Inconsistent UX
One team shows a spinner. Another shows a skeleton. A third disables the button. A fourth does nothing and lets the UI hang. Without a standardized async state model, your application's loading and error experiences are inconsistent by default.
3. Bug-Prone Cleanup
Forgetting takeUntilDestroyed or missing an unsubscribe leads to memory leaks. Setting loading = false in only one branch of a subscription handler leads to stuck loading states. These are easy to miss in code review because they're "obvious" — until they're not.
4. Testing Friction
Testing imperative async state requires simulating timing, asserting on multiple properties, and verifying cleanup. The test mirrors the complexity of the component.
5. Cognitive Load
Developers shouldn't have to think about async plumbing. They should think about business logic. Scattered state forces every developer to be an async state management expert.
The contrarian truth: The biggest problem with async code isn't subscriptions — it's scattered state. You can have perfect RxJS operators and still ship a broken UX because your loading flag is three properties away from your data.
Meet resource(): Angular's Signal-Native Async Primitive
The Resource API, introduced in Angular's Signals ecosystem, provides a single reactive primitive for component-level asynchronous state.
Here's the same component, rewritten:
import { Component, inject, signal, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface User {
id: number;
name: string;
email: string;
role: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
@if (userResource.isLoading()) {
<div class="skeleton-loader">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
</div>
} @else if (userResource.error()) {
<div class="error-banner">
<p>{{ userResource.error() }}</p>
<button (click)="userResource.reload()">Retry</button>
</div>
} @else {
<div class="user-card">
<img [src]="userResource.value()?.avatar" [alt]="userResource.value()?.name" />
<h2>{{ userResource.value()?.name }}</h2>
<p>{{ userResource.value()?.email }}</p>
<span class="badge">{{ userResource.value()?.role }}</span>
</div>
}
`
})
export class UserProfileComponent {
private http = inject(HttpClient);
userId = signal(1);
userResource = resource({
request: () => ({ id: this.userId() }),
loader: ({ request }) =>
this.http.get<User>(`/api/users/${request.id}`)
});
}
What just happened?
- 5 properties became 1 resource.
- Manual subscription handling became automatic lifecycle management.
- Imperative reload logic became reactive dependency tracking.
- Manual cleanup became automatic cancellation.
The resource() function returns a Resource object with these signal-based properties:
| Property | Type | Description |
|---|---|---|
.value() |
`Signal<T \ | undefined>` |
.isLoading() |
Signal<boolean> |
true while the loader is executing |
.error() |
Signal<unknown> |
Any error thrown by the loader |
.hasValue() |
Signal<boolean> |
true if data has been successfully loaded |
.reload() |
() => void |
Manually trigger a re-fetch with the same request |
.status() |
Signal<ResourceStatus> |
'idle', 'loading', 'resolved', 'error', or 'reloading'
|
All of these are Signals. They integrate seamlessly with computed(), effect(), and Angular's change detection.
How resource() Works Under the Hood
Understanding the internals helps you use it correctly. Here's the execution model:
The Resource Contract
Signal Dependency Change
│
▼
┌─────────────────────┐
│ request() runs │ ← Re-evaluates whenever tracked signals change
│ (must be pure) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Previous request │ ← Automatically cancelled via AbortController
│ cancelled │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ loader() executes │ ← Receives { request, abortSignal }
│ (can be async) │
└──────────┬──────────┘
│
┌─────┴─────┐
▼ ▼
Success Error
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ .value()│ │ .error()│
│ updates │ │ updates │
│ .isLoading()│ │ .isLoading()│
│ = false │ │ = false │
└─────────┘ └─────────┘
Key Behaviors
Dependency Tracking: The
requestfunction is executed within a reactive context. Any Signal read insiderequestbecomes a dependency. When that Signal changes, the resource automatically reloads.Automatic Cancellation: When a new request starts, the previous in-flight request is cancelled via an
AbortSignalpassed to the loader. This prevents race conditions and memory leaks.State Synchronization: The resource's internal state transitions are atomic. You never have a frame where
isLoading()isfalsebutvalue()is still stale, or whereerror()andvalue()are both set.Lazy Evaluation: The resource only starts loading when it's first read in a reactive context (e.g., in a template or
computed()). If the resource is never consumed, no request is made.
The Automatic Lifecycle: Cancel, Reload, Update
Let's trace what happens when a dependency changes:
@Component({...})
export class ProductListComponent {
private http = inject(HttpClient);
// These are reactive dependencies
categoryId = signal('electronics');
sortBy = signal<'price' | 'name'>('name');
page = signal(1);
productsResource = resource({
request: () => ({
category: this.categoryId(),
sort: this.sortBy(),
page: this.page()
}),
loader: async ({ request, abortSignal }) => {
const params = new URLSearchParams({
category: request.category,
sort: request.sort,
page: request.page.toString()
});
const response = await fetch(`/api/products?${params}`, {
signal: abortSignal // Automatic cancellation wired in
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json() as Promise<Product[]>;
}
});
}
Scenario: User changes sort order
-
sortBy.set('price')is called. - Angular's Signals system detects the change.
- The
requestfunction re-runs, producing{ category: 'electronics', sort: 'price', page: 1 }. - The resource compares the new request to the previous one. It's different, so a reload triggers.
- The previous
fetchcall receives anabortsignal and is cancelled. -
isLoading()becomestrue. - The new
fetchstarts. - On resolution,
value()updates with the new products,isLoading()becomesfalse. - Any
computed()oreffect()that readsproductsResource.value()re-evaluates automatically.
All of this happens without you writing a single line of imperative state management.
Developer Experience: What Changes in Your Day-to-Day
Before: Imperative Mental Model
// 1. Set loading
// 2. Clear error
// 3. Make request
// 4. Handle success
// 5. Handle error
// 6. Set loading false
// 7. Handle cleanup
// 8. Handle userId changes
// 9. Handle refresh
After: Declarative Mental Model
// 1. Define what you need
// 2. Define how to get it
// 3. Let Angular handle the rest
The shift is from "How do I manage this state?" to "What is the relationship between my data and my UI?"
This is the same mental model shift that made React's useEffect powerful, but with Angular's Signals providing fine-grained reactivity and the Resource API providing a purpose-built abstraction for async state.
Architecture Patterns with resource()
Pattern 1: Basic CRUD with HttpClient
The most common use case: fetch data when a component loads, show loading/error/success states.
import { Component, inject, signal, resource } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Post {
id: number;
title: string;
body: string;
authorId: number;
}
@Component({
selector: 'app-post-detail',
standalone: true,
imports: [CommonModule],
template: `
<article class="post-container">
@if (postResource.isLoading()) {
<post-skeleton />
} @else if (postResource.error()) {
<error-fallback
[error]="postResource.error()"
(retry)="postResource.reload()"
/>
} @else {
@let post = postResource.value();
<header>
<h1>{{ post?.title }}</h1>
<span class="meta">Post #{{ post?.id }}</span>
</header>
<div class="content">{{ post?.body }}</div>
<div class="actions">
<button (click)="postResource.reload()">🔄 Refresh</button>
<button (click)="editPost()">✏️ Edit</button>
</div>
}
</article>
`
})
export class PostDetailComponent {
private http = inject(HttpClient);
// Route parameter as a signal (Angular v16.1+)
postId = input.required<number>();
postResource = resource({
request: () => ({ id: this.postId() }),
loader: ({ request }) =>
this.http.get<Post>(`/api/posts/${request.id}`)
});
editPost(): void {
// Navigation logic
}
}
Key insight: The postId signal input (from input.required) automatically triggers resource reloads when the route changes. No ngOnInit, no ActivatedRoute subscription, no manual cleanup.
Pattern 2: Parameterized Resources with Multiple Dependencies
Real-world components often depend on multiple reactive values: filters, pagination, sorting, search queries.
import { Component, inject, signal, resource, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
interface ProductFilters {
search: string;
category: string | null;
minPrice: number;
maxPrice: number;
inStockOnly: boolean;
}
@Component({
selector: 'app-product-catalog',
standalone: true,
template: `
<div class="catalog-layout">
<!-- Filter Sidebar -->
<aside class="filters">
<input
[value]="filters().search"
(input)="updateSearch($any($event).target.value)"
placeholder="Search products..."
/>
<select (change)="updateCategory($any($event).target.value)">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<label>
<input
type="checkbox"
[checked]="filters().inStockOnly"
(change)="toggleStockFilter()"
/>
In Stock Only
</label>
</aside>
<!-- Product Grid -->
<main class="product-grid">
@if (productsResource.isLoading()) {
<product-grid-skeleton [count]="12" />
} @else if (productsResource.error()) {
<error-state
[error]="productsResource.error()"
(retry)="productsResource.reload()"
/>
} @else {
@let products = productsResource.value();
<div class="results-header">
<span>{{ productCount() }} products found</span>
<span>Page {{ currentPage() }} of {{ totalPages() }}</span>
</div>
<div class="grid">
@for (product of products; track product.id) {
<product-card [product]="product" />
} @empty {
<empty-state message="No products match your filters" />
}
</div>
<pagination
[currentPage]="currentPage()"
[totalPages]="totalPages()"
(pageChange)="goToPage($event)"
/>
}
</main>
</div>
`
})
export class ProductCatalogComponent {
private http = inject(HttpClient);
// Individual filter signals
searchQuery = signal('');
selectedCategory = signal<string | null>(null);
priceRange = signal({ min: 0, max: 10000 });
inStockOnly = signal(false);
currentPage = signal(1);
pageSize = signal(24);
// Combined filter object — stable reference matters
filters = computed(() => ({
search: this.searchQuery(),
category: this.selectedCategory(),
minPrice: this.priceRange().min,
maxPrice: this.priceRange().max,
inStockOnly: this.inStockOnly(),
page: this.currentPage(),
pageSize: this.pageSize()
}));
productsResource = resource({
request: () => this.filters(),
loader: ({ request }) => {
const params = new HttpParams()
.set('search', request.search)
.set('minPrice', request.minPrice.toString())
.set('maxPrice', request.maxPrice.toString())
.set('inStock', request.inStockOnly.toString())
.set('page', request.page.toString())
.set('pageSize', request.pageSize.toString());
if (request.category) {
params.set('category', request.category);
}
return this.http.get<{ items: Product[]; total: number }>(
'/api/products',
{ params }
);
}
});
// Derived state
productCount = computed(() =>
this.productsResource.value()?.items.length ?? 0
);
totalPages = computed(() => {
const total = this.productsResource.value()?.total ?? 0;
return Math.ceil(total / this.pageSize());
});
// Actions
updateSearch(query: string): void {
this.searchQuery.set(query);
this.currentPage.set(1); // Reset to first page on filter change
}
updateCategory(category: string): void {
this.selectedCategory.set(category || null);
this.currentPage.set(1);
}
toggleStockFilter(): void {
this.inStockOnly.update(v => !v);
this.currentPage.set(1);
}
goToPage(page: number): void {
this.currentPage.set(page);
}
}
Key insight: The filters computed signal creates a stable, serializable request object. When any individual filter changes, the computed re-evaluates, the resource detects the new request, and automatically reloads. The currentPage.set(1) calls ensure UX consistency (resetting pagination when filters change).
Pattern 3: Derived State with computed()
Resources integrate naturally with computed() for derived state that updates automatically.
@Component({...})
export class OrderSummaryComponent {
private http = inject(HttpClient);
orderId = input.required<string>();
orderResource = resource({
request: () => ({ id: this.orderId() }),
loader: ({ request }) =>
this.http.get<Order>(`/api/orders/${request.id}`)
});
// Derived computed signals — all reactive
orderTotal = computed(() => {
const order = this.orderResource.value();
if (!order) return 0;
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
orderStatus = computed(() => {
const order = this.orderResource.value();
if (!order) return 'unknown' as const;
if (order.cancelledAt) return 'cancelled' as const;
if (order.shippedAt) return 'shipped' as const;
if (order.paidAt) return 'paid' as const;
return 'pending' as const;
});
isActionable = computed(() => {
const status = this.orderStatus();
return status === 'pending' || status === 'paid';
});
formattedTotal = computed(() => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(this.orderTotal());
});
}
Key insight: All computed signals are lazy and memoized. They only re-evaluate when orderResource.value() changes, not on every change detection cycle. This is performance-efficient by design.
Pattern 4: Side Effects with effect()
Use effect() for side effects that should run when resource state changes — analytics, toast notifications, logging, or cross-component communication.
@Component({...})
export class CheckoutComponent {
private http = inject(HttpClient);
private analytics = inject(AnalyticsService);
private toast = inject(ToastService);
cartId = input.required<string>();
checkoutResource = resource({
request: () => ({ cartId: this.cartId() }),
loader: ({ request }) =>
this.http.post<CheckoutResult>('/api/checkout', {
cartId: request.cartId
})
});
constructor() {
// Effect runs whenever tracked signals change
effect(() => {
const status = this.checkoutResource.status();
if (status === 'resolved') {
const result = this.checkoutResource.value();
this.analytics.track('checkout_completed', {
orderId: result?.orderId,
total: result?.total
});
this.toast.success('Order placed successfully!');
}
if (status === 'error') {
const error = this.checkoutResource.error();
this.analytics.track('checkout_failed', { error: String(error) });
this.toast.error('Checkout failed. Please try again.');
}
});
}
placeOrder(): void {
// Trigger the resource manually if needed
this.checkoutResource.reload();
}
}
Key insight: Effects run asynchronously after the DOM updates. They're the right place for side effects because they don't block rendering and they automatically track their dependencies.
Caution: Never call .reload() inside an effect that reads the same resource — this creates an infinite loop.
Pattern 5: Service Integration
Resources should live in components, but they can delegate to services for business logic and HttpClient calls.
// Service layer — pure business logic, no state
@Injectable({ providedIn: 'root' })
export class ProjectService {
private http = inject(HttpClient);
getProject(id: number): Observable<Project> {
return this.http.get<Project>(`/api/projects/${id}`).pipe(
map(project => ({
...project,
// Transform raw API response
displayName: project.name.replace(/-/g, ' ')
}))
);
}
updateProject(id: number, changes: Partial<Project>): Observable<Project> {
return this.http.patch<Project>(`/api/projects/${id}`, changes);
}
getProjectMembers(projectId: number): Observable<User[]> {
return this.http.get<User[]>(`/api/projects/${projectId}/members`);
}
}
// Component layer — async state management
@Component({
selector: 'app-project-dashboard',
standalone: true,
template: `
<div class="dashboard">
@if (projectResource.isLoading()) {
<dashboard-skeleton />
} @else if (projectResource.error()) {
<error-state (retry)="projectResource.reload()" />
} @else {
@let project = projectResource.value();
<project-header [project]="project" />
<project-stats [project]="project" />
<!-- Nested resource for related data -->
<project-members [projectId]="project!.id" />
}
</div>
`
})
export class ProjectDashboardComponent {
private projectService = inject(ProjectService);
projectId = input.required<number>();
projectResource = resource({
request: () => ({ id: this.projectId() }),
loader: ({ request }) =>
// Convert Observable to Promise for resource
firstValueFrom(this.projectService.getProject(request.id))
});
}
// Child component with its own resource
@Component({
selector: 'project-members',
standalone: true,
template: `
@if (membersResource.isLoading()) {
<member-list-skeleton />
} @else {
<ul>
@for (member of membersResource.value(); track member.id) {
<li>{{ member.name }} — {{ member.role }}</li>
}
</ul>
}
`
})
export class ProjectMembersComponent {
private projectService = inject(ProjectService);
projectId = input.required<number>();
membersResource = resource({
request: () => ({ id: this.projectId() }),
loader: ({ request }) =>
firstValueFrom(this.projectService.getProjectMembers(request.id))
});
}
Key insight: Services remain the source of truth for business logic and HTTP calls. Resources live in components and manage the async state lifecycle. Use firstValueFrom() to bridge RxJS Observables into the Promise-based resource loader.
Pattern 6: SSR and Hydration
Resources work seamlessly with Angular's server-side rendering and hydration.
@Component({
selector: 'app-blog-post',
standalone: true,
template: `
<article>
@if (postResource.isLoading()) {
<content-skeleton />
} @else if (postResource.error()) {
<error-fallback />
} @else {
@let post = postResource.value();
<h1>{{ post?.title }}</h1>
<time>{{ post?.publishedAt | date }}</time>
<div [innerHTML]="post?.content"></div>
}
</article>
`
})
export class BlogPostComponent {
private http = inject(HttpClient);
private platformId = inject(PLATFORM_ID);
slug = input.required<string>();
postResource = resource({
request: () => ({ slug: this.slug() }),
loader: async ({ request }) => {
// On the server, this runs during SSR
// On the client, Angular hydrates the state automatically
const response = await fetch(`/api/posts/${request.slug}`);
if (!response.ok) {
throw new Error(`Failed to load post: ${response.status}`);
}
return response.json() as Promise<BlogPost>;
}
});
}
Key insight: During SSR, the resource loader runs on the server, and the resolved state is serialized into the HTML. During hydration on the client, Angular restores this state without re-fetching. The resource transitions from 'resolved' immediately, providing instant interactivity.
Pattern 7: Error Boundaries and Retry
Resources provide granular error state, which you can use to build sophisticated error handling.
@Component({
selector: 'app-data-fetcher',
standalone: true,
template: `
<div class="data-container">
@if (dataResource.isLoading()) {
<loading-spinner />
} @else if (dataResource.error()) {
<error-card
[error]="dataResource.error()"
[attemptCount]="retryCount()"
(retry)="handleRetry()"
(dismiss)="dismissError()"
/>
} @else {
<data-visualization [data]="dataResource.value()" />
}
</div>
`
})
export class DataFetcherComponent {
private http = inject(HttpClient);
endpoint = input.required<string>();
retryCount = signal(0);
maxRetries = 3;
dataResource = resource({
request: () => ({
endpoint: this.endpoint(),
retry: this.retryCount()
}),
loader: async ({ request, abortSignal }) => {
try {
const response = await fetch(request.endpoint, { signal: abortSignal });
if (!response.ok) {
throw new HttpErrorResponse({
error: await response.text(),
status: response.status,
statusText: response.statusText,
url: request.endpoint
});
}
return response.json();
} catch (error) {
// Log for monitoring
console.error('Resource load failed:', error);
throw error;
}
}
});
handleRetry(): void {
if (this.retryCount() < this.maxRetries) {
this.retryCount.update(c => c + 1);
this.dataResource.reload();
}
}
dismissError(): void {
// Could navigate away or show cached data
}
}
Key insight: By including retryCount in the request object, the resource reloads automatically when the user triggers a retry. The error state is fully reactive — you can build error UIs as complex as your product requires.
Pattern 8: Optimistic Updates
While resources are primarily for fetching, you can combine them with local state for optimistic UI patterns.
@Component({...})
export class TaskBoardComponent {
private taskService = inject(TaskService);
boardId = input.required<string>();
// Server state
tasksResource = resource({
request: () => ({ boardId: this.boardId() }),
loader: ({ request }) =>
firstValueFrom(this.taskService.getTasks(request.boardId))
});
// Optimistic local state for drag-and-drop
localTaskOrder = signal<Task[] | null>(null);
// Displayed tasks: prefer local optimistic state
displayedTasks = computed(() =>
this.localTaskOrder() ?? this.tasksResource.value() ?? []
);
async moveTask(taskId: string, newIndex: number): Promise<void> {
const currentTasks = this.displayedTasks();
const task = currentTasks.find(t => t.id === taskId);
if (!task) return;
// 1. Optimistically update local state
const reordered = [...currentTasks];
const oldIndex = reordered.findIndex(t => t.id === taskId);
reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, task);
this.localTaskOrder.set(reordered);
try {
// 2. Send to server
await firstValueFrom(
this.taskService.moveTask(this.boardId(), taskId, newIndex)
);
// 3. On success, clear optimistic state (resource will reflect truth)
this.localTaskOrder.set(null);
this.tasksResource.reload();
} catch (error) {
// 4. On failure, revert to server state
this.localTaskOrder.set(null);
// Error handling via UI notification
}
}
}
Key insight: The computed() displayedTasks prefers local optimistic state when available, falling back to the resource. This pattern keeps the resource as the source of truth while allowing temporary local overrides.
Template Patterns: Loading, Error, and Success UI
Progressive Disclosure Pattern
Show the most useful state at each point in the lifecycle:
@Component({
template: `
<div class="content-area">
<!-- Always show header, even while loading -->
<header>
<h1>Dashboard</h1>
<button
(click)="dashboardResource.reload()"
[disabled]="dashboardResource.isLoading()"
>
@if (dashboardResource.isLoading()) {
<spinner size="sm" />
} @else {
🔄 Refresh
}
</button>
</header>
@if (dashboardResource.isLoading() && !dashboardResource.hasValue()) {
<!-- Initial load: full skeleton -->
<dashboard-skeleton />
} @else if (dashboardResource.error() && !dashboardResource.hasValue()) {
<!-- Initial load failed: full error state -->
<error-fullscreen
[error]="dashboardResource.error()"
(retry)="dashboardResource.reload()"
/>
} @else {
<!-- We have data (might be stale while reloading) -->
<div class="dashboard-content" [class.stale]="dashboardResource.isLoading()">
@if (dashboardResource.isLoading()) {
<!-- Background reload indicator -->
<div class="reload-banner">
<spinner size="sm" /> Updating...
</div>
}
@if (dashboardResource.error() && dashboardResource.hasValue()) {
<!-- Background reload failed, but we have stale data -->
<div class="stale-warning">
⚠️ Couldn't refresh. Showing data from {{ lastUpdated() }}.
</div>
}
<!-- Main content -->
<metrics-grid [metrics]="dashboardResource.value()?.metrics" />
<activity-chart [data]="dashboardResource.value()?.activity" />
</div>
}
</div>
`
})
export class DashboardComponent {
private http = inject(HttpClient);
dashboardResource = resource({
request: () => ({}),
loader: () => this.http.get<DashboardData>('/api/dashboard')
});
lastUpdated = computed(() => {
const data = this.dashboardResource.value();
return data ? new Date(data.timestamp).toLocaleTimeString() : 'unknown';
});
}
Skeleton-First Pattern
For routes where data is expected, show skeletons immediately:
@Component({
template: `
@if (profileResource.isLoading()) {
<profile-skeleton />
} @else if (profileResource.error()) {
<profile-error [error]="profileResource.error()" />
} @else {
<profile-content [profile]="profileResource.value()!" />
}
`
})
export class ProfilePageComponent {
private http = inject(HttpClient);
userId = input.required<string>();
profileResource = resource({
request: () => ({ id: this.userId() }),
loader: ({ request }) =>
this.http.get<UserProfile>(`/api/users/${request.id}/profile`)
});
}
Empty State Pattern
Handle the case where data loads successfully but is empty:
@Component({
template: `
@if (notificationsResource.isLoading()) {
<notification-skeleton [count]="3" />
} @else if (notificationsResource.error()) {
<error-toast [error]="notificationsResource.error()" />
} @else {
@let notifications = notificationsResource.value() ?? [];
@if (notifications.length === 0) {
<empty-state
icon="🔔"
title="No notifications"
description="You're all caught up!"
/>
} @else {
<notification-list [items]="notifications" />
}
}
`
})
export class NotificationCenterComponent {
private http = inject(HttpClient);
notificationsResource = resource({
request: () => ({}),
loader: () => this.http.get<Notification[]>('/api/notifications')
});
}
When NOT to Use the Resource API
The Resource API is powerful, but it's not universal. Here are the scenarios where you should not use it:
1. Complex Event Streams
If your data source is a continuous stream of events (SSE, long-polling with incremental updates), resource() is the wrong abstraction. Use RxJS Observable with proper stream handling.
// ❌ Don't do this
const streamResource = resource({
request: () => ({}),
loader: () => {
// This will only capture the FIRST event
return new Promise(resolve => {
eventSource.onmessage = (e) => resolve(JSON.parse(e.data));
});
}
});
// ✅ Use RxJS instead
const eventStream = new Observable<Message>(subscriber => {
const es = new EventSource('/api/events');
es.onmessage = (e) => subscriber.next(JSON.parse(e.data));
return () => es.close();
});
2. WebSockets
WebSocket connections are persistent, bidirectional, and stateful. The request/response model of resource() doesn't map to this paradigm.
// ✅ Use RxJS webSocket or native WebSocket
const wsSubject = webSocket('wss://api.example.com/realtime');
wsSubject.subscribe(msg => /* handle message */);
3. Multi-Stream Orchestration
When you need combineLatest, merge, forkJoin, or complex operator chains, RxJS is the right tool.
// ✅ RxJS for orchestration
const dashboardData$ = forkJoin({
metrics: metricsService.getMetrics(),
alerts: alertService.getActiveAlerts(),
users: userService.getOnlineUsers()
}).pipe(
debounceTime(100),
distinctUntilChanged()
);
4. Heavy Reactive Pipelines
If your data flow requires switchMap, debounceTime, throttleTime, scan, or custom operators, stay with RxJS.
// ✅ RxJS for complex pipelines
searchResults$ = this.searchQuery$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.searchService.search(query)),
catchError(err => of([]))
);
5. Non-Component State
Global state (Redux/NgRx stores, shared services) should not use resource(). Resources are component-scoped by design.
Where Each Tool Fits: HttpClient, RxJS, and Resource API
| Layer | Tool | Role |
|---|---|---|
| Network | HttpClient |
HTTP requests, interceptors, error handling at the protocol level |
| Stream Logic | RxJS | Complex async pipelines, event streams, multi-source orchestration |
| Component State | resource() |
Component-level async state lifecycle (loading, error, data) |
| Derived State | computed() |
Transformations based on signals/resources |
| Side Effects | effect() |
Reactions to state changes (analytics, toasts, logging) |
The senior mindset: Resource API isn't replacing HttpClient. It's improving how components consume asynchronous data. Each tool has a different architectural role.
┌─────────────────────────────────────────────┐
│ Presentation Layer │
│ Template: @if (res.isLoading()) │
│ {{ res.value() }} │
├─────────────────────────────────────────────┤
│ Component Logic Layer │
│ resource({ request, loader }) │
│ computed(() => transform(res.value())) │
│ effect(() => sideEffect(res.status())) │
├─────────────────────────────────────────────┤
│ Service Layer │
│ HttpClient.get() / Observable pipelines │
│ Business logic, data transformation │
├─────────────────────────────────────────────┤
│ Network Layer │
│ HTTP / WebSocket / Server-Sent Events │
└─────────────────────────────────────────────┘
Testing Strategies
Testing Resources with TestBed
import { ComponentFixture, TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe('UserProfileComponent', () => {
let fixture: ComponentFixture<UserProfileComponent>;
let component: UserProfileComponent;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserProfileComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting()
]
}).compileComponents();
fixture = TestBed.createComponent(UserProfileComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should show loading state initially', () => {
fixture.componentRef.setInput('userId', 1);
fixture.detectChanges();
expect(component.userResource.isLoading()).toBe(true);
expect(component.userResource.value()).toBeUndefined();
});
it('should display user data after successful load', fakeAsync(() => {
const mockUser: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'admin'
};
fixture.componentRef.setInput('userId', 1);
fixture.detectChanges();
// Flush the pending HTTP request
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
tick(); // Let microtasks resolve
fixture.detectChanges();
expect(component.userResource.isLoading()).toBe(false);
expect(component.userResource.value()).toEqual(mockUser);
expect(component.userResource.error()).toBeNull();
}));
it('should handle HTTP errors', fakeAsync(() => {
fixture.componentRef.setInput('userId', 999);
fixture.detectChanges();
const req = httpMock.expectOne('/api/users/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
tick();
fixture.detectChanges();
expect(component.userResource.isLoading()).toBe(false);
expect(component.userResource.value()).toBeUndefined();
expect(component.userResource.error()).toBeTruthy();
}));
it('should reload when userId changes', fakeAsync(() => {
fixture.componentRef.setInput('userId', 1);
fixture.detectChanges();
httpMock.expectOne('/api/users/1').flush({ id: 1, name: 'User 1', email: '', role: '' });
tick();
// Change the input
fixture.componentRef.setInput('userId', 2);
fixture.detectChanges();
expect(component.userResource.isLoading()).toBe(true);
httpMock.expectOne('/api/users/2').flush({ id: 2, name: 'User 2', email: '', role: '' });
tick();
fixture.detectChanges();
expect(component.userResource.value()?.name).toBe('User 2');
}));
});
Testing Resources in Isolation
import { resource, signal } from '@angular/core';
describe('resource() behavior', () => {
it('should track loading state through lifecycle', async () => {
const id = signal(1);
const res = resource({
request: () => ({ id: id() }),
loader: async ({ request }) => {
await new Promise(r => setTimeout(r, 10));
return { id: request.id, data: 'test' };
}
});
// Initial state
expect(res.isLoading()).toBe(true);
expect(res.value()).toBeUndefined();
// Wait for resolution
await new Promise(r => setTimeout(r, 20));
expect(res.isLoading()).toBe(false);
expect(res.value()).toEqual({ id: 1, data: 'test' });
});
it('should cancel previous request on dependency change', async () => {
const id = signal(1);
const abortSignals: AbortSignal[] = [];
const res = resource({
request: () => ({ id: id() }),
loader: async ({ abortSignal }) => {
abortSignals.push(abortSignal);
await new Promise(r => setTimeout(r, 100));
return { id: id() };
}
});
// Trigger first request
id.set(1);
await new Promise(r => setTimeout(r, 10));
// Trigger second request before first completes
id.set(2);
await new Promise(r => setTimeout(r, 10));
// First abort signal should be aborted
expect(abortSignals[0].aborted).toBe(true);
expect(abortSignals[1].aborted).toBe(false);
});
});
Performance Considerations
1. Lazy Evaluation
Resources only execute their loader when their state is read in a reactive context. If a resource is declared but never consumed in a template or computed(), no request is made.
@Component({...})
export class LazyComponent {
// This resource won't load until something reads it
heavyResource = resource({
request: () => ({}),
loader: () => fetchExpensiveData()
});
// Only when the user clicks "Show Details" does the resource activate
showDetails = signal(false);
// Template: @if (showDetails()) { {{ heavyResource.value() }} }
}
2. Memoization
The request function's return value is compared to the previous request using Object.is(). If the request hasn't changed, no reload occurs.
// ✅ Good: Stable request object
request: () => ({ id: this.userId() })
// ❌ Bad: New object every time (causes infinite reloads)
request: () => ({ id: this.userId(), timestamp: Date.now() })
// ✅ Fix: Only include reactive dependencies
request: () => ({ id: this.userId() })
3. Cancellation Prevents Race Conditions
Automatic cancellation means you never have to worry about out-of-order responses:
// Without resource: You'd need switchMap
this.searchQuery$.pipe(
debounceTime(300),
switchMap(q => this.http.get(`/api/search?q=${q}`))
).subscribe();
// With resource: Cancellation is built-in
searchResource = resource({
request: () => ({ q: this.searchQuery() }),
loader: ({ request }) => this.http.get(`/api/search?q=${request.q}`)
});
4. Change Detection Efficiency
Because resources use Signals, change detection is fine-grained. Only components that read the resource's signals are marked for check, not the entire component tree.
Migration Strategy: Moving from Traditional Patterns
Step 1: Identify Candidates
Look for components with this pattern:
-
loadingboolean property -
errorproperty -
dataproperty -
subscribe()call - Manual cleanup
Step 2: Extract the Request Shape
Determine what signals drive the request:
// Before: Imperative
ngOnInit() {
this.loadData(this.userId, this.filters);
}
onFilterChange(filters) {
this.filters = filters;
this.loadData(this.userId, this.filters);
}
// After: Reactive
filters = signal(defaultFilters);
resource({
request: () => ({ userId: this.userId(), filters: this.filters() }),
loader: ({ request }) => this.service.load(request.userId, request.filters)
});
Step 3: Migrate Template
Replace scattered property checks with resource state checks:
<!-- Before -->
@if (loading) { <skeleton /> }
@else if (error) { <error /> }
@else { <content [data]="data" /> }
<!-- After -->
@if (resource.isLoading()) { <skeleton /> }
@else if (resource.error()) { <error /> }
@else { <content [data]="resource.value()" /> }
Step 4: Remove Imperative Code
Delete:
-
loading = falseassignments -
error = nullresets -
takeUntilDestroyedpipes - Manual
unsubscribecalls -
ngOnInitfetch logic -
refresh()methods (use.reload())
Step 5: Test
Resources are more predictable to test because state transitions are centralized. Update tests to assert on resource state rather than individual properties.
Best Practices Summary
| # | Practice | Rationale |
|---|---|---|
| 1 | Include all reactive dependencies in request |
Ensures automatic reload when anything changes |
| 2 | Keep loaders pure — no side effects | Side effects belong in effect() or event handlers |
| 3 | Use .reload() for user-initiated refresh |
Don't recreate resources; reload them |
| 4 | Use @if control flow with resource state |
Clean, readable, signal-native template handling |
| 5 | Combine with computed() for derived state |
Avoid duplicating transformation logic |
| 6 | Let resources handle cancellation | Don't manually abort; dependency changes do it automatically |
| 7 | Keep HttpClient in services |
Resources use services; services use HttpClient
|
| 8 | Use signal inputs for route parameters |
input.required<number>() → automatic resource reload |
| 9 | Handle .error() in template, not loader |
Separation of concerns: loader fetches, template presents |
| 10 | Use firstValueFrom() for Observable services |
Bridge RxJS services into Promise-based resource loaders |
| 11 | Avoid creating resources inside effect() |
Creates infinite loops and memory leaks |
| 12 | Test resource state, not implementation | Assert on .value(), .isLoading(), .error()
|
Common Mistakes to Avoid
Mistake 1: Side Effects in Loader
// ❌ Wrong: Side effect in loader
loader: async ({ request }) => {
const data = await fetchData(request.id);
this.analytics.track('data_loaded'); // Side effect!
return data;
}
// ✅ Correct: Pure loader + effect for side effects
loader: async ({ request }) => fetchData(request.id)
constructor() {
effect(() => {
if (this.resource.status() === 'resolved') {
this.analytics.track('data_loaded');
}
});
}
Mistake 2: Unstable Request Objects
// ❌ Wrong: New object every evaluation
request: () => ({ id: this.id(), random: Math.random() })
// ✅ Correct: Only reactive dependencies
request: () => ({ id: this.id() })
Mistake 3: Forgetting Error States
// ❌ Wrong: No error handling
@if (resource.isLoading()) { <loading /> }
@else { <data [value]="resource.value()" /> }
// ✅ Correct: All three states
@if (resource.isLoading()) { <loading /> }
@else if (resource.error()) { <error [err]="resource.error()" /> }
@else { <data [value]="resource.value()" /> }
Mistake 4: Calling .reload() in effect()
// ❌ Wrong: Infinite loop
constructor() {
effect(() => {
console.log(this.resource.value());
this.resource.reload(); // Triggers effect again!
});
}
// ✅ Correct: Use event handlers
onRefreshClick() {
this.resource.reload();
}
Mistake 5: Using Resources for Non-Async State
// ❌ Wrong: Resource for synchronous data
const nameResource = resource({
request: () => ({}),
loader: () => Promise.resolve(this.user().name) // Not async!
});
// ✅ Correct: Use computed for sync transformations
const displayName = computed(() => this.user().name.toUpperCase());
Enterprise Considerations
Standardization Across Teams
Large Angular applications benefit when Loading, Errors, Success, Refresh, and Cancellation are modeled consistently across every feature. The Resource API helps standardize that experience.
Recommended enterprise pattern: Create a set of reusable resource wrappers and UI components:
// Shared resource factory with enterprise defaults
@Injectable({ providedIn: 'root' })
export class ResourceFactory {
private http = inject(HttpClient);
createApiResource<T, R>(options: {
endpoint: (request: R) => string;
transform?: (raw: unknown) => T;
}) {
return (requestSignal: Signal<R>) =>
resource({
request: () => options.endpoint(requestSignal()),
loader: async ({ request }) => {
const response = await fetch(request);
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
const raw = await response.json();
return options.transform ? options.transform(raw) : raw as T;
}
});
}
}
// Shared UI components
@Component({
selector: 'async-container',
standalone: true,
template: `
@if (resource.isLoading() && !resource.hasValue()) {
<ng-content select="[loading]" />
} @else if (resource.error() && !resource.hasValue()) {
<ng-content select="[error]" />
} @else {
<ng-content select="[content]" />
}
`
})
export class AsyncContainerComponent<T> {
resource = input.required<Resource<T>>();
}
Onboarding Benefits
New developers only need to learn one async pattern instead of five different team conventions. The resource contract (value(), isLoading(), error(), reload()) is self-documenting.
Monitoring and Observability
Centralized resource patterns make it easier to add global interceptors for:
- Request timing metrics
- Error rate tracking
- Loading state analytics
- Cache hit/miss ratios
Conclusion: The Architecture Mindset Shift
The Resource API represents more than a new function in Angular. It represents a shift in how we architect component-level asynchronous state.
From imperative to declarative. Instead of telling Angular how to manage loading, error, and data states, we declare what data we need and when it should refresh. Angular handles the lifecycle.
From scattered to centralized. Instead of spreading async state across multiple properties and methods, we have a single reactive object that owns the entire lifecycle.
from manual to automatic. Instead of writing cancellation, cleanup, and reload logic by hand, we leverage Signals' dependency tracking and Angular's built-in lifecycle management.
The golden rule remains: Treat asynchronous data as state — not as a side effect.
The Resource API is a strong fit for component-level asynchronous state. Traditional HttpClient remains valuable for many scenarios. RxJS still shines with complex event streams, WebSockets, and multi-stream orchestration. These tools complement one another.
In modern Angular applications, the question is no longer "How do I fetch data?" but "How do I model async state so my components stay predictable, testable, and maintainable?"
resource() is Angular's answer to that question.
Resources & References
- Angular Signals Documentation
- Angular Resource API (Official)
- RxJS Documentation
- Angular Control Flow
I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy — follow along for more breakdowns like this one.
📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
🌐 Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.
Top comments (0)