httpResource (Angular 21)
What is httpResource?
httpResource is an Angular 21 primitive from @angular/common/http that provides declarative HTTP data loading. It returns a Resource signal that automatically handles loading states, errors, and provides reactive updates.
Why Use It?
- ✅ Declarative data loading - URL-based resource definition
- ✅ Automatic state management - Loading, error, and value states
- ✅ Signal-based - Returns
Resource<T>signal - ✅ Manual reload - Trigger refetch with
reload() - ✅ Type-safe - Full TypeScript support
- ✅ Simpler than HttpClient - Less boilerplate
httpResource vs HttpClient
| Feature | HttpClient | httpResource |
|---|---|---|
| API | Observable-based | Signal-based Resource |
| Loading state | Manual | Automatic |
| Error state | Manual try/catch | Automatic |
| Subscription | Required | Not required |
| Cleanup | Manual unsubscribe | Automatic |
| Result type | Observable<T> | Resource<T> |
| Reload | Re-call method | Call .reload() |
Basic Usage
- Service Setup
- Component Usage
- Resource States
import { Injectable, inject } from '@angular/core';
import { HttpClient, httpResource } from '@angular/common/http';
import { Album } from '../models/album.model';
@Injectable({ providedIn: 'root' })
export class AlbumService {
http = inject(HttpClient);
// Define the httpResource
readonly _albumsResource = httpResource<Album[]>(() => {
return 'http://localhost:3000/albums';
}, { defaultValue: [] });
// Expose as a getter
get albumsResource() {
return this._albumsResource;
}
// Traditional HttpClient method (for comparison)
findAll(): Observable<Album[]> {
return this.http.get<Album[]>('http://localhost:3000/albums');
}
}
Key points:
- Returns a URL string from the loader function
- Automatically creates a GET request
defaultValueprovides initial value before data loads- Resource is read-only, exposed via getter
import { Component, inject, computed } from '@angular/core';
import { AlbumService } from './services/album.service';
@Component({
selector: 'app-album-list',
standalone: true,
template: `
<div>
<!-- Loading state -->
@if (albumsResource.isLoading()) {
<p>Loading albums...</p>
}
<!-- Error state -->
@if (albumsResource.error()) {
<p class="error">Error: {{ albumsResource.error()?.message }}</p>
}
<!-- Data -->
@if (albums(); as albumList) {
<div class="album-grid">
@for (album of albumList; track album.id) {
<div class="album-card">
<h3>{{ album.name }}</h3>
<p>{{ album.artist }}</p>
<p class="price">{{ album.price | currency }}</p>
</div>
}
</div>
}
<!-- Reload button -->
<button (click)="albumsResource.reload()">
🔄 Reload Albums
</button>
<!-- Status -->
<p>Status: {{ albumsResource.status() }}</p>
</div>
`
})
export class AlbumListComponent {
private albumService = inject(AlbumService);
// Get the resource from service
albumsResource = this.albumService.albumsResource;
// Extract value using computed
albums = computed(() => this.albumsResource.value());
// Resource provides:
// - value() → Album[] | undefined
// - isLoading() → boolean
// - error() → Error | undefined
// - status() → ResourceStatus
// - reload() → void (trigger refetch)
}
Resource provides 4 key signals:
// 1. value() - The loaded data
const albums = this.albumsResource.value();
// Type: Album[] | undefined
// 2. isLoading() - Loading state
const loading = this.albumsResource.isLoading();
// Type: boolean
// 3. error() - Error state
const err = this.albumsResource.error();
// Type: Error | undefined
// 4. status() - Overall status
const status = this.albumsResource.status();
// Type: ResourceStatus
// Values: ResourceStatus.Idle | ResourceStatus.Loading |
// ResourceStatus.Error | ResourceStatus.Resolved
State transitions:
Initial → Idle (with defaultValue)
↓
Loading → isLoading() = true
↓
Success → value() = data, isLoading() = false, status() = Resolved
OR
Error → error() = Error, isLoading() = false, status() = Error
Reload After Mutations
A common pattern is to reload the resource after creating, updating, or deleting items:
- Service with CRUD
- Reload After Create
@Injectable({ providedIn: 'root' })
export class AlbumService {
http = inject(HttpClient);
// Resource for GET
readonly _albumsResource = httpResource<Album[]>(() => {
return 'http://localhost:3000/albums';
}, { defaultValue: [] });
get albumsResource() {
return this._albumsResource;
}
// Traditional methods for mutations
save(album: Album): Observable<Album> {
return this.http.post<Album>('http://localhost:3000/albums', album);
}
update(album: Album): Observable<Album> {
return this.http.put<Album>(`http://localhost:3000/albums/${album.id}`, album);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`http://localhost:3000/albums/${id}`);
}
}
import { Component, inject } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AlbumService } from './services/album.service';
import { AddAlbumDialogComponent } from './add-album-dialog.component';
@Component({
selector: 'app-album-list',
standalone: true,
template: `
<button (click)="openAddDialog()">➕ Add Album</button>
@for (album of albums(); track album.id) {
<div class="album-card">
<h3>{{ album.name }}</h3>
<p>{{ album.artist }}</p>
<button (click)="deleteAlbum(album.id)">🗑️ Delete</button>
</div>
}
`
})
export class AlbumListComponent {
private albumService = inject(AlbumService);
private dialog = inject(MatDialog);
albumsResource = this.albumService.albumsResource;
albums = computed(() => this.albumsResource.value());
openAddDialog() {
const dialogRef = this.dialog.open(AddAlbumDialogComponent);
// Reload resource after dialog closes
dialogRef.afterClosed().subscribe(() => {
this.albumsResource.reload();
});
}
deleteAlbum(id: number) {
this.albumService.delete(id).subscribe({
next: () => {
// Reload resource after successful delete
this.albumsResource.reload();
},
error: (err) => console.error('Delete failed:', err)
});
}
}
Pattern:
- Perform mutation (POST, PUT, DELETE) using HttpClient
- On success, call
resource.reload()to refetch data - Resource automatically updates all computed values
Advanced Patterns
Default Value
Provide initial data while loading:
readonly albumsResource = httpResource<Album[]>(() => {
return 'http://localhost:3000/albums';
}, {
defaultValue: [] // Start with empty array, not undefined
});
Benefits:
- Prevents
undefinederrors in templates - Can show placeholder data
- Smoother UX during initial load
Computed Derived Values
Use computed() to derive additional signals:
export class AlbumListComponent {
albumsResource = this.albumService.albumsResource;
// Extract value
albums = computed(() => this.albumsResource.value() ?? []);
// Derived computations
totalAlbums = computed(() => this.albums().length);
highlightedAlbums = computed(() =>
this.albums().filter(a => a.highlighted)
);
totalPrice = computed(() =>
this.albums().reduce((sum, a) => sum + a.price, 0)
);
averagePrice = computed(() => {
const albums = this.albums();
return albums.length > 0
? this.totalPrice() / albums.length
: 0;
});
}
Error Handling in Template
template: `
@if (albumsResource.error(); as err) {
<div class="error-banner">
<h3>⚠️ Failed to load albums</h3>
<p>{{ err.message }}</p>
<button (click)="albumsResource.reload()">
🔄 Retry
</button>
</div>
}
`
Loading Skeleton
template: `
@if (albumsResource.isLoading()) {
<div class="skeleton-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="skeleton-card">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
}
</div>
}
@if (albums(); as albumList) {
<!-- Real content -->
}
`
Comparison with rxResource
| Feature | httpResource | rxResource |
|---|---|---|
| Import from | @angular/common/http | @angular/core/rxjs-interop |
| Loader | Returns URL string | Returns Observable |
| HTTP Method | GET only | Any (via Observable) |
| Use case | Simple GET requests | Complex Observable chains |
| Example | () => '/api/albums' | () => http.get('/api/albums') |
When to use httpResource:
- ✅ Simple GET requests
- ✅ URL-based loading
- ✅ No complex RxJS operators needed
When to use rxResource:
- ✅ POST, PUT, DELETE requests
- ✅ Complex Observable pipelines
- ✅ Need for RxJS operators (map, filter, etc.)
- ✅ Multiple HTTP calls combined
httpResource vs HttpClient
- Traditional HttpClient
- Modern httpResource
// ❌ HttpClient approach - Manual state management
@Component({
selector: 'app-album-list',
template: `
@if (loading) {
<p>Loading...</p>
}
@if (error) {
<p class="error">{{ error }}</p>
}
@if (!loading && !error) {
@for (album of albums; track album.id) {
<div>{{ album.name }}</div>
}
}
`
})
export class AlbumListComponent implements OnInit, OnDestroy {
albums: Album[] = [];
loading = false;
error: string | null = null;
private destroy$ = new Subject<void>();
ngOnInit() {
this.loadAlbums();
}
loadAlbums() {
this.loading = true;
this.error = null;
this.albumService.findAll()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.albums = data;
this.loading = false;
},
error: (err) => {
this.error = err.message;
this.loading = false;
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Issues:
- Manual loading/error state management
- Need lifecycle hooks (OnInit, OnDestroy)
- Manual subscription management
- Manual cleanup with
takeUntil - More boilerplate code
// ✅ httpResource approach - Automatic state management
@Component({
selector: 'app-album-list',
template: `
@if (albumsResource.isLoading()) {
<p>Loading...</p>
}
@if (albumsResource.error(); as err) {
<p class="error">{{ err.message }}</p>
}
@for (album of albums(); track album.id) {
<div>{{ album.name }}</div>
}
`
})
export class AlbumListComponent {
private albumService = inject(AlbumService);
albumsResource = this.albumService.albumsResource;
albums = computed(() => this.albumsResource.value() ?? []);
// That's it! No ngOnInit, no subscriptions, no cleanup
}
Benefits:
- ✅ Automatic state management
- ✅ No lifecycle hooks needed
- ✅ No manual subscriptions
- ✅ Automatic cleanup
- ✅ Less code, clearer intent
Real-World Example
Complete example from student project (step-17):
// album.service.ts
@Injectable({ providedIn: 'root' })
export class AlbumService {
http = inject(HttpClient);
readonly _albumsResource = httpResource<Album[]>(() => {
console.log('Loading albums...');
return 'http://localhost:3000/albums';
}, { defaultValue: [] });
get albumsResource() {
return this._albumsResource;
}
save(album: Album): Observable<Album> {
return this.http.post<Album>('http://localhost:3000/albums', album);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`http://localhost:3000/albums/${id}`);
}
}
// album-list.component.ts
@Component({
selector: 'app-album-list',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: `
<div class="container">
<h1>Album Catalog</h1>
@if (albumsResource.isLoading()) {
<div class="loading">
<p>Loading albums...</p>
</div>
}
@if (albumsResource.error(); as err) {
<div class="error">
<p>Failed to load albums: {{ err.message }}</p>
<button (click)="albumsResource.reload()">Retry</button>
</div>
}
@if (albums(); as albumList) {
<div class="stats">
<p>Total Albums: {{ totalAlbums() }}</p>
<p>Average Price: {{ averagePrice() | currency }}</p>
</div>
<div class="album-grid">
@for (album of albumList; track album.id) {
<div class="album-card">
<h3>{{ album.name }}</h3>
<p class="artist">{{ album.artist }}</p>
<p class="price">{{ album.price | currency }}</p>
<div class="tags">
@for (tag of album.tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
<button (click)="deleteAlbum(album.id)">Delete</button>
</div>
}
</div>
}
</div>
`
})
export class AlbumListComponent {
private albumService = inject(AlbumService);
albumsResource = this.albumService.albumsResource;
albums = computed(() => this.albumsResource.value() ?? []);
totalAlbums = computed(() => this.albums().length);
averagePrice = computed(() => {
const albums = this.albums();
if (albums.length === 0) return 0;
return albums.reduce((sum, a) => sum + a.price, 0) / albums.length;
});
deleteAlbum(id: number) {
this.albumService.delete(id).subscribe({
next: () => this.albumsResource.reload(),
error: (err) => console.error('Delete failed:', err)
});
}
}
Best Practices
- ✅ Use
httpResourcefor simple GET requests - ✅ Provide
defaultValueto avoid undefined errors - ✅ Keep resource private (
_albumsResource), expose via getter - ✅ Use
computed()to derive values from resource - ✅ Call
reload()after mutations (POST, PUT, DELETE) - ✅ Handle errors gracefully in template with retry option
- ✅ Show loading states for better UX
- ❌ Don't mutate resource data directly
- ❌ Don't use for POST/PUT/DELETE (use HttpClient instead)
- ❌ Don't forget to provide type parameter:
httpResource<Album[]>()
Common Patterns
Pattern 1: Resource with Computed Filters
albumsResource = this.albumService.albumsResource;
albums = computed(() => this.albumsResource.value() ?? []);
// Filtered views
rockAlbums = computed(() =>
this.albums().filter(a => a.tags.includes('Rock'))
);
highlightedAlbums = computed(() =>
this.albums().filter(a => a.highlighted)
);
Pattern 2: Reload on User Action
refreshAlbums() {
this.albumsResource.reload();
}
createAlbum(album: Album) {
this.albumService.save(album).subscribe({
next: () => this.albumsResource.reload()
});
}
Pattern 3: Error Recovery
template: `
@if (albumsResource.error()) {
<div class="error-banner">
<p>Something went wrong!</p>
<button (click)="albumsResource.reload()">Try Again</button>
</div>
}
`
Related Documentation
- rxResource - Observable-based resource API
- HttpClient - Traditional HTTP client
- State Management - Managing application state
- Album Model - Data model reference
- Signals - Signal fundamentals
Project Reference
See this pattern in action:
- Service:
src/app/services/album.service.ts - Component:
src/app/components/albums/album-list/album-list.component.ts - Learning Path: Day 3, Module 3.6 - Resource API
Last Updated: December 2024 Angular Version: 21+ Status: Current best practice for declarative HTTP loading