Angular HTTP: Loading State, Error Handling and POST Requests

Angular Tutorial: Beginner to Advanced · 10. HTTP and RxJS · 4 min read · Updated

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 loading in both the next and error handlers, otherwise the spinner never stops.
  • OnInit runs 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.
  • @empty renders when the list has no items.

Try it yourself

  1. Change the URL to a wrong address and check that the error message and Retry button appear.
  2. Disable the Add button when the title is empty.
  3. Add a "Delete" button that calls http.delete and removes the item from the list.

Quick check

  1. What status does HttpErrorResponse have when the server cannot be reached?
  2. Why reset loading in the error handler too?
  3. What does retry(1) do?

Next, continue with Angular HTTP: Query Parameters, Headers, PUT, PATCH and DELETE Requests.

Frequently asked questions

Should I use interceptors for errors?

Yes, for app-wide concerns such as adding an auth token or logging every failure. Keep screen-specific messages in the component or store.

Does the sample API save my new post?

No. JSONPlaceholder fakes the response, so the new post is shown in the list but not stored on the server.