Angular: Allow Users Retry Failed Requests With A Twitter-Like Try Again Button

Twitter homepage with internet disconnected

Leonel Elimpe
by Leonel Elimpe
3 min read

Tags

  • Angular
  • Featured
  • Retry Failed Requests

While using the Twitter web app, I noticed it displays a Try Again button for failed requests in different sections of the user interface. This allows the user retry each failed request without affecting the rest of the application, quite neat.

In this post, we’re going to implement similar functionality in 3 steps using Angular, RxJS, Bootstrap 4, and good old DOM events 🙂.

Feel free to use the UI library of your choice, full project source code is available on Github.

1. Create a service that exposes the user’s connection status

import {Injectable} from '@angular/core';
import {fromEvent, merge, of} from 'rxjs';
import {mapTo} from 'rxjs/operators';

@Injectable({providedIn: 'root'})
export class ConnectionStatusService {

  /**
   * This code returning a false value means you're absolutely offline as in disconnected.
   * It returning true doesn't necessarily indicate that there's a practically usable connection.
   *
   * @see https://stackoverflow.com/a/39573363/6924437
   * @see https://justmarkup.com/articles/2016-08-18-indicating-offline/
   */
  readonly online$: Observable<boolean> = merge(
    of(navigator.onLine),
    fromEvent(window, 'online').pipe(mapTo(true)),
    fromEvent(window, 'offline').pipe(mapTo(false)),
  );

}

online$ above returning a false value means you’re absolutely offline as in disconnected. It returning true doesn’t necessarily indicate that there’s a practically usable connection. See this Stackoverflow answer for a more elaborate explanation.

On a lighter note, if you’ve got a better name for the above service, please use the comments. Naming is hard 😅.

2. Create a component to display the retry button and error message

import {Component, EventEmitter, Input, Output} from '@angular/core';
import {ConnectionStatusService} from '../../services/connection-status.service';
import {Observable} from 'rxjs';

@Component({
  selector: 'app-try-again',
  template: `
    <div class="text-center">
      <ng-container *ngIf="(isOnline$ | async) === false">
        <span class="text-muted pb-5">
          📶🚫 <small>Offline</small>
        </span>
      </ng-container>
      <p class="text-muted"></p>
      <button (click)="emitTryAgain()" class="btn btn-primary">
        Try Again
      </button>
    </div>

  `,
})
export class TryAgainComponent {

  /** Message to display to user in the template */
  @Input() message = 'Something went wrong.';

  /** Event emitted when the user clicks the Try Again button */
  @Output() tryAgain = new EventEmitter<boolean>();

  constructor(private connectionStatusService: ConnectionStatusService) { }

  /** Emits the tryAgain event */
  emitTryAgain() {
    this.tryAgain.emit(true);
  }

  /**
   * Getter for connection service's online$ property.
   * Primary use case is to avoid directly accessing the service in the template.
   */
  get isOnline$(): Observable<boolean> {
    return this.connectionStatusService.online$;
  }

}

In the template, we display a message, as well as a Try Again button which when clicked emits the tryAgain event. We equally check if the user is offline and display an appropriate offline icon and message.

3. Use the component to retry failed requests

For this demo, we’re going to fetch Post and Todo items using the JSONPlaceholder API. We’ll display them in seperate ‘boxes’ in the UI, adding our above retry button for each resource.

import {Component, OnInit} from '@angular/core';
import {Observable, throwError} from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {catchError, tap} from 'rxjs/operators';
import {IPost} from './models/post.interface';
import {ITodo} from './models/todo.interface';


@Component({
  selector: 'app-root',
  template: `
    <div class="container">

      <h1 class="text-center pb-5 pt-4">Retry Failed Requests From UI</h1>

      <div class="row">

        <!-- Posts start -->
        <div class="col-md-6">

          <h4 class="text-center">POSTS</h4>

          <ul class="list-group" *ngIf="posts$ | async; let posts">
            <li class="list-group-item" *ngFor="let post of posts"></li>
          </ul>

          <ng-container *ngIf="fetchPostsFailed">
            <app-try-again (tryAgain)="fetchPosts()"
                           message="Failed to load Posts.">

            </app-try-again>
          </ng-container>

        </div>
        <!-- Posts end -->

        <!-- Todos start -->
        <div class="col-md-6">

          <h4 class="text-center">TODOS</h4>

          <ul class="list-group" *ngIf="todos$ | async; let todos">
            <li class="list-group-item" *ngFor="let todo of todos"></li>
          </ul>

          <ng-container *ngIf="fetchTodosFailed">
            <app-try-again (tryAgain)="fetchTodos()"
                           message="Failed to load Todos.">

            </app-try-again>
          </ng-container>

        </div>
        <!-- Todos end -->

      </div>

    </div>

  `,
})
export class AppComponent implements OnInit {

  posts$: Observable<IPost[]>;
  fetchPostsFailed: boolean;

  todos$: Observable<ITodo[]>;
  fetchTodosFailed: boolean;

  constructor(private http: HttpClient) { }

  ngOnInit() {
    this.fetchPosts();
    this.fetchTodos();
  }

  fetchPosts() {
    this.posts$ = this.http.get<IPost[]>('https://jsonplaceholder.typicode.com/posts')
      .pipe(
        // fetch is successful so set failed to false
        tap(() => this.fetchPostsFailed = false),
        // fetch has failed so set failed to true
        catchError((error) => {
          this.fetchPostsFailed = true;
          return throwError(error);
        }),
      );

  }

  fetchTodos() {
    this.todos$ = this.http.get<ITodo[]>('https://jsonplaceholder.typicode.com/todos')
      .pipe(
        // fetch is successful so set failed to false
        tap(() => this.fetchTodosFailed = false),
        // fetch has failed so set failed to true
        catchError((error) => {
          this.fetchTodosFailed = true;
          return throwError(error);
        }),
      );

  }

}

In the component, we keep track of failed requests with fetchPostsFailed and fetchTodosFailed for posts and todos respectively. We then use them in the template to determine if a request has failed and show the retry button.

Checkout the demo application, and while on the page, disconnect from the internet, then click any of the Reload buttons to see the Try Again button appear. Also notice as soon as you reconnect to the internet, the offline icon and text are hidden. Clicking on the Try Again button re-triggers the (appropriate) http request and should it succeed, the Try Again button button is hidden.