DEV Community

Rens Jaspers
Rens Jaspers

Posted on • Edited on

Reactive Loading State Management with RxJS and Angular: Loading Stuff Based on Changes to Other Stuff

When you build apps with Angular, you often need to get data when something changes in your app. These changes could come from a user's action, like typing in a search box, or from the app itself, such as a change in the route parameters. Today, we're going to learn some effective ways to reactively fetch data in response to these changes.

Responding to user input

Imagine a scenario where we want to show search results as the user types into a search box:

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" />

    <div *ngIf="result$ | async as result">
        {{ result }}
    </div>
  `,
})
export class App {
  // We are using a FormControl to watch the user's input
  searchControl = new FormControl('');

  result$ = this.searchControl.valueChanges.pipe(
    switchMap(keywords => this.http.get('/api/search', {params: { q: keywords }))
  );

  private http = inject(HttpClient);
}
Enter fullscreen mode Exit fullscreen mode

In this example, searchControl.valueChanges is an Observable. This Observable sends out an event each time the user changes the text in the search box. We then use something called the switchMap operator from RxJS. This operator lets us make a fresh data request each time the user changes the text. It also makes sure we only work with the results for the latest text input. This is very important for keeping your app speedy and pleasant for the user.

We use *ngIf with the async pipe so that the template automatically connects to our result$ Observable and updates itself. This makes sure that the template always shows the newest search results to the user in real-time as they type into the search box.

Managing loading and error states with RxJS

A key part of getting data is giving feedback to the user during the entire loading process. This could be a loading message or an error message. Instead of using separate isLoading or hasError variables in our components, we'll use some RxJS operators called map, catchError, startWith, and scan.

Let's have a look at the following example:

interface LoadingState<T = unknown> {
  loading: boolean;
  error?: Error | null;
  data?: T;
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" />

    <div *ngIf="result$ | async as result">
      <div *ngIf="result.loading">Loading...</div>
      <div *ngIf="result.data as data">{{data}}</div>
      <div *ngIf="result.error as error">{{error.message}}</div>
    </div>
  `,
})
export class App {
  searchControl = new FormControl('');

  result$ = this.searchControl.valueChanges.pipe(
    switchMap((value) =>
      this.http.get('/api/search', {params: { q: value }).pipe(
        map((data) => ({ data, loading: false })),
        catchError((error) => of({ error, loading: false })),
        startWith({ error: null, loading: true })
      )
    ),
    scan((state: LoadingState<string>, change: LoadingState<string>) => ({
      ...state,
      ...change,
    }))
  );

  private http = inject(HttpClient);
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  1. We use map to change the raw data from the HTTP request into an object. This object includes the data and a loading flag that is set to false.

  2. We use catchError to manage any errors that happen during the HTTP request. If an error happens, it gives back an Observable with an object. This object includes the error and a loading flag that is set to false.

  3. We use startWith to begin the stream with a specific value. Here, it starts with an object that includes a null error and a loading flag set to true. This makes sure that the user sees a loading state before any data is loaded.

  4. scan works like Array.reduce. It takes the previous state and the new changes and mixes them into a new state. Here, it takes the previous loading state and blends it with the changes from the HTTP request result. This way, we keep a constant stream of state changes, each adding in the changes of the one before it.

  5. Now, in your template, you can use Angular's async pipe to subscribe to result$, and use *ngIf to show different parts of the template based on the loading state.

These operators look complicated! Why not just use separate isLoading and hasError variables?

Even if it looks easier to use this.isLoading or this.hasError variables in your component, using RxJS operators gives you better control.

When your loading and error states are inside the data stream, they're tied to the loading process. They can't be changed by anything else. This prevents unexpected changes and bugs.

If you use separate variables, other parts of your app could change them. This could lead to bugs that are hard to track down and fix.

Keeping these states inside the data stream makes your code less complicated and safer. It also makes sure the user interface updates smoothly.

Important: keep it DRY! Create your own reusable RxJS Operator

You probably need the pattern we discussed above in multiple places in your app. But you don’t want to write the same code over and over again. Luckily, it's not hard to make your own RxJS operators! We can take the code we used before and make a useful RxJS operator that we can use all over our app.

Let's look at an example: we'll make our own operator called switchMapWithLoading. We'll put it in a separate file named switch-map-with-loading.ts.

// switch-map-with-loading.ts

interface LoadingState<T = unknown> {
  loading: boolean;
  error?: Error | null;
  data?: T;
}

export function switchMapWithLoading<T>(
  observableFunction: (value: any) => Observable<T>
): OperatorFunction<any, LoadingState<T>> {
  return (source: Observable<any>) =>
    source.pipe(
      switchMap((value) =>
        observableFunction(value).pipe(
          map((data) => ({ data, loading: false })),
          catchError((error) => of({ error, loading: false })),
          startWith({ error: null, loading: true })
        )
      ),
      scan((state: LoadingState<T>, change: LoadingState<T>) => ({
        ...state,
        ...change,
      }))
    );
}
Enter fullscreen mode Exit fullscreen mode

You can then import it in your component and use it like so:

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <input [formControl]="searchControl" />

    <div *ngIf="result$ | async as result">
      <div *ngIf="result.loading">Loading...</div>
      <div *ngIf="result.data as data">{{data}}</div>
      <div *ngIf="result.error as error">{{error.message}}</div>
    </div>
  `,
})
export class App {
  searchControl = new FormControl('');

  result$ = this.searchControl.valueChanges.pipe(
    switchMapWithLoading((value) => this.http.get('/api/search', {params: { q: value })))
  );

  private http = inject(HttpClient);
}
Enter fullscreen mode Exit fullscreen mode

With this, you've made your Angular application code more efficient and maintainable by abstracting the reactive data fetching mechanism into a custom RxJS operator, which you can use across your application. This will not only keep your code DRY (Don't Repeat Yourself) but also make it easier to understand and manage.

Making it even simpler with *ngxLoadWith

Now, you might be wondering if there's a simpler way to handle all of this without dealing with complex RxJS operators. Good news! Introducing the *ngxLoadWith directive, which provides a simpler approach to managing loading states in your Angular application.

To get started, you need to install the *ngxLoadWith package. Run the following command:

npm install ngx-load-with
Enter fullscreen mode Exit fullscreen mode

Once installed, you can use the *ngxLoadWith directive in your Angular component like this:

@Component({
  selector: "my-app",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, NgxLoadWithModule],
  template: `
    <input [formControl]="searchControl" />

    <div
      *ngxLoadWith="
        getResult as data;
        args: searchControl.value;
        loadingTemplate: loading;
        errorTemplate: error
      "
    >
      {{ data }}
    </div>
    <ng-template #loading>Loading...</ng-template>
    <ng-template #error let-error>{{ error.message }}</ng-template>
  `,
})
export class App {
  searchControl = new FormControl("");

  getResult = (keywords: string) =>
    this.http.get("/api/search", { params: { q: keywords } });

  private http = inject(HttpClient);
}
Enter fullscreen mode Exit fullscreen mode

Let's break it down. We simply define a function called getResult that takes a keywords parameter and returns an HTTP request. We then pass this function to the *ngxLoadWith directive, along with the value of searchControl.value as the args input.

Now whenever the searchControl value changes, the getResult function will be called with the new value. The *ngxLoadWith directive will then subscribe to the result of the function and display the result in the template.

Then we define two templates: loading and error. These templates will be automatically displayed when the getResult function is loading or has an error.

That's it! You can now easily and safely manage loading states in your Angular application without having to deal with complex RxJS operators.

Ready to give it a try? Check out the source code at github.com/rensjaspers/ngx-load-with. If you find *ngxLoadWith helpful, don't forget to give it a star on Github. We would love to hear your feedback and experiences with this directive!

Top comments (0)