A good screen handles three states: loading, success and failure. In this lesson you add loading and error messages to the post list and send new data with a POST request.
What you will learn
- How to track loading and error state with signals
- How to catch errors and retry
- How to create data with
POST
Extend the service
// src/app/posts/post-api.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Post } from './post';
export interface NewPost {
title: string;
body: string;
userId: number;
}
@Injectable({ providedIn: 'root' })
export class PostApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = 'https://jsonplaceholder.typicode.com/posts';
getPosts(): Observable<Post[]> {
return this.http.get<Post[]>(this.baseUrl);
}
getPost(id: number): Observable<Post> {
return this.http.get<Post>(`${this.baseUrl}/${id}`);
}
createPost(post: NewPost): Observable<Post> {
return this.http.post<Post>(this.baseUrl, post);
}
}
A store with loading and error state
// src/app/posts/post-store.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { retry } from 'rxjs';
import { Post } from './post';
import { NewPost, PostApi } from './post-api';
@Injectable({ providedIn: 'root' })
export class PostStore {
private readonly api = inject(PostApi);
readonly posts = signal<Post[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly saving = signal(false);
load(): void {
this.loading.set(true);
this.error.set(null);
this.api
.getPosts()
.pipe(retry(1)) // try one more time before giving up
.subscribe({
next: (list) => {
this.posts.set(list.slice(0, 10));
this.loading.set(false);
},
error: (err: HttpErrorResponse) => {
this.error.set(this.messageFor(err));
this.loading.set(false);
},
});
}
add(post: NewPost): void {
this.saving.set(true);
this.api.createPost(post).subscribe({
next: (created) => {
// Show the new post first. The test API does not really store it.
this.posts.update((list) => [created, ...list]);
this.saving.set(false);
},
error: (err: HttpErrorResponse) => {
this.error.set(this.messageFor(err));
this.saving.set(false);
},
});
}
private messageFor(err: HttpErrorResponse): string {
if (err.status === 0) {
return 'Cannot reach the server. Check your connection and try again.';
}
if (err.status === 404) {
return 'The data was not found.';
}
return 'Something went wrong. Please try again later.';
}
}
The store lives in a service, so it follows the pattern from the shared state lesson. retry(1) re-sends the request once if it fails. An HttpErrorResponse has status 0 when the request never reached a server.
The component
// src/app/posts/post-list.ts
import { Component, inject, OnInit } from '@angular/core';
import { PostStore } from './post-store';
@Component({
selector: 'app-post-list',
template: `
<h2>Posts</h2>
<form (submit)="create($event, titleInput.value, bodyInput.value); titleInput.value = ''; bodyInput.value = ''">
<p>
<label for="title">Title</label><br />
<input #titleInput id="title" name="title" required />
</p>
<p>
<label for="body">Body</label><br />
<textarea #bodyInput id="body" name="body" required></textarea>
</p>
<button type="submit" [disabled]="store.saving()">
{{ store.saving() ? 'Saving...' : 'Add post' }}
</button>
</form>
@if (store.error(); as message) {
<p role="alert">{{ message }} <button type="button" (click)="store.load()">Retry</button></p>
}
@if (store.loading()) {
<p role="status">Loading posts...</p>
} @else {
<ul>
@for (post of store.posts(); track post.id) {
<li><strong>{{ post.title }}</strong></li>
} @empty {
<li>No posts yet.</li>
}
</ul>
}
`,
})
export class PostList implements OnInit {
protected readonly store = inject(PostStore);
ngOnInit(): void {
this.store.load();
}
protected create(event: Event, title: string, body: string): void {
event.preventDefault();
if (!title.trim() || !body.trim()) {
return;
}
this.store.add({ title: title.trim(), body: body.trim(), userId: 1 });
}
}
The key parts
- Three signals (
loading,error,posts) describe the screen state, and the template reacts to them. - Always reset
loadingin both thenextanderrorhandlers, otherwise the spinner never stops. OnInitruns once after the component is created. It is the usual place to start the first load.- Show the user a clear message and a Retry button. Do not show raw error text from the server.
@emptyrenders when the list has no items.
Try it yourself
- Change the URL to a wrong address and check that the error message and Retry button appear.
- Disable the Add button when the title is empty.
- Add a "Delete" button that calls
http.deleteand removes the item from the list.
Quick check
- What
statusdoesHttpErrorResponsehave when the server cannot be reached? - Why reset
loadingin the error handler too? - What does
retry(1)do?
Next, continue with Angular HTTP: Query Parameters, Headers, PUT, PATCH and DELETE Requests.