Loading States, Error Boundaries and Offline Handling

๐Ÿ“‹ Table of Contents โ–พ
  1. HttpStateManager and Loading Patterns
  2. Common Mistakes

Production HTTP UX requires consistent handling of loading states, errors, offline detection, and request cancellation. Components that display data should show a spinner while loading, a meaningful error message when the API fails, and handle the user navigating away while a request is in flight. A reusable HttpStateManager pattern wraps any Observable and manages these states automatically, reducing boilerplate across all data-fetching components in the BlogApp.

HttpStateManager and Loading Patterns

// โ”€โ”€ Reusable state manager wrapping any Observable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
export class HttpStateManager<T> {
  private _data    = signal<T | null>(null);
  private _loading = signal(false);
  private _error   = signal<string | null>(null);

  readonly data    = this._data.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error   = this._error.asReadonly();
  readonly hasData = computed(() => this._data() !== null);

  execute(source$: Observable<T>, destroyRef: DestroyRef): void {
    this._loading.set(true);
    this._error.set(null);

    source$.pipe(
      takeUntilDestroyed(destroyRef),   // cancel if component destroyed
      finalize(() => this._loading.set(false)),
    ).subscribe({
      next:  data  => this._data.set(data),
      error: err   => {
        const message = err instanceof HttpErrorResponse
          ? this.formatError(err)
          : (err.message ?? 'An unexpected error occurred.');
        this._error.set(message);
      },
    });
  }

  private formatError(err: HttpErrorResponse): string {
    if (err.status === 0)   return 'Network error. Check your connection.';
    if (err.status === 404) return 'The requested resource was not found.';
    if (err.status === 429) {
      const wait = err.headers.get('Retry-After') ?? '60';
      return `Too many requests. Please wait ${wait} seconds.`;
    }
    return err.error?.title ?? `Error ${err.status}.`;
  }
}

// โ”€โ”€ Component using HttpStateManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Component({
  standalone:  true,
  imports:    [PostCardComponent],
  template: `
    @if (state.loading()) {
      <app-spinner />
    } @else if (state.error()) {
      <app-error-message [message]="state.error()!" (retry)="load()" />
    } @else if (state.hasData()) {
      @for (post of state.data()!.items; track post.id) {
        <app-post-card [post]="post" />
      }
    }
  `,
})
export class PostListComponent implements OnInit {
  private api        = inject(PostsApiService);
  private destroyRef = inject(DestroyRef);
  protected state    = new HttpStateManager<PagedResult<PostSummaryDto>>();

  ngOnInit(): void { this.load(); }

  load(): void {
    this.state.execute(this.api.getPublished(), this.destroyRef);
  }
}

// โ”€โ”€ Offline detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@Injectable({ providedIn: 'root' })
export class OnlineStatusService {
  private _isOnline = signal(navigator.onLine);
  readonly isOnline = this._isOnline.asReadonly();
  readonly isOffline = computed(() => !this._isOnline());

  constructor() {
    fromEvent(window, 'online')
      .pipe(takeUntilDestroyed())
      .subscribe(() => this._isOnline.set(true));

    fromEvent(window, 'offline')
      .pipe(takeUntilDestroyed())
      .subscribe(() => this._isOnline.set(false));
  }
}

// โ”€โ”€ Template using offline status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// @if (onlineStatus.isOffline()) {
//   <div class="offline-banner">You are offline. Reconnect to load content.</div>
// }
Note: takeUntilDestroyed(destroyRef) automatically cancels the Observable subscription when the component is destroyed. Without it, an HTTP request that has not completed when the user navigates away continues to run in the background, and when it finally resolves, the component’s signal update attempts to run on a destroyed component. In development mode this causes ExpressionChangedAfterItHasBeenCheckedError; in production it can cause subtle state corruption. Always add takeUntilDestroyed() to long-running subscriptions in components.
Tip: Implement a retry mechanism in the error UI: an ErrorMessageComponent that accepts a (retry) output button allows users to re-trigger the request after a transient failure. This is more user-friendly than requiring a page reload. In the component’s load() method, reset the state and re-execute the Observable. This simple pattern covers 80% of transient error UX needs without complex retry infrastructure in the HTTP layer.
Warning: The navigator.onLine property is not perfectly reliable โ€” it returns true if the device has any network connection, even if that connection has no internet access (captive portal, VPN with no route to the API). For detecting actual API reachability, implement a lightweight heartbeat request (GET /health/live) that can definitively confirm the API is reachable. Use navigator.onLine for quick offline detection, but fall back to heartbeat pings for definitive connectivity status.

Common Mistakes

Mistake 1 โ€” Not cancelling in-flight requests when component is destroyed

โŒ Wrong โ€” component destroyed while request is pending; response arrives, attempts to update destroyed component’s signals.

โœ… Correct โ€” add .pipe(takeUntilDestroyed(destroyRef)) to all component-level subscriptions.

Mistake 2 โ€” No retry option for users on transient failures (forced page reload)

โŒ Wrong โ€” error displayed with no way to retry; user must reload the entire page.

โœ… Correct โ€” provide a retry button that re-triggers the data load method.

🧠 Test Yourself

A user navigates away from the post list while a 2-second API call is in flight. Without takeUntilDestroyed(), what happens when the response arrives?