DEV Community

Cover image for How to Easily Notify Users About Task Progress in Angular
Rens Jaspers
Rens Jaspers

Posted on • Edited on

How to Easily Notify Users About Task Progress in Angular

I found myself repeatedly writing the same code to notify users of the status of asynchronous tasks — whether the task was still in progress, completed successfully, or had failed.

In this post, I’ll show you how to create a TaskStatusNotifierService to handle these notifications, reducing the amount of repetitive code and letting you focus on the task’s actual logic.

I'll use Ionic in my example, but you can modify the service to work with any Angular project.

The Problem

In every component, I kept doing the following:

  1. Injecting these three controllers:
  • LoadingController (to show a spinner while the task runs)
  • ToastController (to show a message when the task completes)
  • AlertController (to show an alert if the task fails)

2. Using the controllers:

  • Create and present a loader before the async task starts, and wait for it to appear on the screen.
  • Run the task, then dismiss the loader and wait for it to disappear.
  • After dismissing the loader, show a toast if the task was successful, or an alert if it failed.

All the Ionic controllers are asynchronous and follow a create/present/dismiss pattern, which adds more code.

The code looked like this:

@Component({})
class MyComponent {
  constructor(
    private loadingController: LoadingController,
    private toastController: ToastController,
    private alertController: AlertController,
    private apiService: ApiService
  ) {}

  async save(data: any) {
    const loader = await this.loadingController.create({
      message: "Saving..."
    });
    await loader.present();

    try {
      await this.apiService.save(data);
      await loader.dismiss();
      const toast = await this.toastController.create({
        message: "Saved successfully",
        duration: 3000,
      });
      await toast.present();
    } catch (error) {
      await loader.dismiss();
      const alert = await this.alertController.create({
        header: "Failed to save",
        message: error.message,
        buttons: ["OK"],
      });
      await alert.present();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Even in this simple example, you can see that the code is repetitive and hard to maintain. If I want to change how I display the loader, toast, or alert globally, I'd have to update every component.

The Solution

Let's create a TaskStatusNotifierService that contains all the repetitive code. This service has a method called run that takes a task and runs it.

Now, I only need to inject the TaskStatusNotifierService in my components and call the run method with the task I want to execute:

@Component({})
class MyComponent {
  constructor(
    private taskStatusNotifierService: TaskStatusNotifierService,
    private apiService: ApiService
  ) {}

  save(data: any) {
    this.taskStatusNotifierService.run(() => this.apiService.save(data));
  }
}
Enter fullscreen mode Exit fullscreen mode

The TaskStatusNotifierService handles showing the loader, running the task, and showing the toast or alert when the task finishes.

Here's how the TaskStatusNotifierService looks:

@Injectable({ providedIn: "root" })
export class TaskStatusNotifierService {
  constructor(
    private loadingController: LoadingController,
    private toastController: ToastController,
    private alertController: AlertController
  ) {}

  async run(task: () => Promise<any>) {
    const loader = await this.loadingController.create({
      message: "Loading..."
    });
    await loader.present();

    try {
      await task();
      await loader.dismiss();
      const toast = await this.toastController.create({
        message: "Task completed successfully",
        duration: 3000,
      });
      await toast.present();
    } catch (error) {
      await loader.dismiss();
      const alert = await this.alertController.create({
        header: "Task failed",
        message: error.message,
        buttons: ["OK"],
      });
      await alert.present();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Observables

If your apiService.save returns an Observable instead of a Promise, you can convert it using lastValueFrom from rxjs:

import { lastValueFrom } from "rxjs";

@Component({})
class MyComponent {
  constructor(
    private taskStatusNotifierService: TaskStatusNotifierService,
    private apiService: ApiService
  ) {}

  save(data: any) {
    this.taskStatusNotifierService.run(() =>
      lastValueFrom(this.apiService.save(data))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Why lastValueFrom?

Some Observables may emit multiple values. In our TaskStatusNotifierService, we care about the last value. We don't want to show a success message and dismiss the loader on the first emission if there are more to come, so we use lastValueFrom.

Customizing Messages

We can update the service to allow customizing the loading, success, and error messages by passing them as parameters:

@Component({})
class MyComponent {
  constructor(
    private taskStatusNotifierService: TaskStatusNotifierService,
    private apiService: ApiService
  ) {}

  save(data: any) {
    this.taskStatusNotifierService.run(() => this.apiService.save(data), {
      loadingMessage: "Saving...",
      successMessage: "Saved successfully",
      errorMessage: "Failed to save",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Update the TaskStatusNotifierService to accept these parameters:

@Injectable({ providedIn: "root" })
export class TaskStatusNotifierService {
  constructor(
    private loadingController: LoadingController,
    private toastController: ToastController,
    private alertController: AlertController
  ) {}

  async run(
    task: () => Promise<any>,
    options: {
      loadingMessage?: string;
      successMessage?: string;
      errorMessage?: string;
    } = {}
  ) {
    const loadingMessage = options.loadingMessage || "Loading...";
    const successMessage =
      options.successMessage || "Task completed successfully";
    const errorMessage = options.errorMessage || "Task failed";

    const loader = await this.loadingController.create({
      message: loadingMessage
    });
    await loader.present();

    try {
      await task();
      await loader.dismiss();
      const toast = await this.toastController.create({
        message: successMessage,
        duration: 3000,
      });
      await toast.present();
    } catch (error) {
      await loader.dismiss();
      const alert = await this.alertController.create({
        header: errorMessage,
        message: error.message,
        buttons: ["OK"],
      });
      await alert.present();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you need more control over the messages, you can pass functions that generate messages based on the result or error:

@Component({})
class MyComponent {
  constructor(
    private taskStatusNotifierService: TaskStatusNotifierService,
    private apiService: ApiService
  ) {}

  save(data: any) {
    this.taskStatusNotifierService.run(() => this.apiService.save(data), {
      loadingMessage: "Saving...",
      getSuccessMessage: (result) => `Saved item. ID: ${result.id}`,
      getErrorMessage: (error) => `Something went wrong: ${error.message}`,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Update the TaskStatusNotifierService accordingly:

@Injectable({ providedIn: "root" })
export class TaskStatusNotifierService {
  constructor(
    private loadingController: LoadingController,
    private toastController: ToastController,
    private alertController: AlertController
  ) {}

  async run(
    task: () => Promise<any>,
    options: {
      loadingMessage?: string;
      getSuccessMessage?: (result: any) => string;
      getErrorMessage?: (error: any) => string;
    } = {}
  ) {
    const loadingMessage = options.loadingMessage || "Loading...";
    const getSuccessMessage =
      options.getSuccessMessage || (() => "Task completed successfully");
    const getErrorMessage = options.getErrorMessage || (() => "Task failed");

    const loader = await this.loadingController.create({
      message: loadingMessage
    });
    await loader.present();

    try {
      const result = await task();
      await loader.dismiss();
      const toast = await this.toastController.create({
        message: getSuccessMessage(result),
        duration: 3000,
      });
      await toast.present();
    } catch (error) {
      await loader.dismiss();
      const alert = await this.alertController.create({
        header: getErrorMessage(error),
        message: error.message,
        buttons: ["OK"],
      });
      await alert.present();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can even extend the service to allow full control over the success and error modals by passing functions that return options for the controllers.

With this TaskStatusNotifierService, you can simplify your code and focus on what really matters—the logic of your tasks. Feel free to expand and customize the service to suit your needs. It's a great starting point for handling asynchronous tasks in your Angular projects.

Top comments (0)