DEV Community

Cover image for When to Use `concatMap`, `mergeMap`, `switchMap`, and `exhaustMap` Operators in Building a CRUD with NgRx
Dany Paredes
Dany Paredes

Posted on • Originally published at danywalls.com

When to Use `concatMap`, `mergeMap`, `switchMap`, and `exhaustMap` Operators in Building a CRUD with NgRx

A few days ago, I was working with NgRx effects in an application,where I needed to get data from the store and combine it with service calls involving asynchronous tasks.

When working with combined streams, it's important to choose the appropriate operator. It depends on the operation we are trying to perform. You can choose between concatMap, exhaustMap, mergeMap, and switchMap.

So, I'm holidays so I will take a few minutes to build something where I need to pick between each one and of course, using API and handle async task with the effects and change state in my reducer.

This article's idea is to stay in shape with NgRx, and try to know which and when use those operators,so let's to id!

This article is part of my series on learning NgRx. If you want to follow along, please check it out.

The Project

I want to create a section in my app to store my favorite places in Menorca. I will use mockAPI.io it is a great service to build fake API and handle CRUD operations.

Clone the repo start-with-ngrx. This project contains examples from my previous posts.

https://github.com/danywalls/start-with-ngrx.git
Enter fullscreen mode Exit fullscreen mode

Switch to the branch crud-ngrx, which includes the following setup:

  • NgRx installed and configured with DevTools.

  • Ready configure with MockAPi.io APi

  • The PlacesService to add, update, remove, and get places.

  • The PlaceComponent, an empty component to load on the places route path.

The Goal

The goal is to practice what we've learned in previous articles by building a CRUD with NgRx and understanding when to use specific RxJS operators. In this article, we will:

  • Create a state for places.

  • Create actions for the CRUD operations.

  • Create selectors to get the places.

  • Use effects to perform create, update, delete, and get actions.

  • Update, and remove places from API.

Let's get started!

If you are only interested in learning about operators, feel free to jump to the implementation section.

Create Places State

We want to handle the state of places, which must store the following:

  • A list of places from the API.

  • The selected place to edit or remove.

  • A loading message while loading data or performing an action.

  • An error message if there's an error when adding, updating, or deleting.

With these points in mind, let's create a new file in places/state/places.state.ts and add the PlaceState definition with a type or interface (whichever you prefer) and declare an initial state for places.

The final code looks like this:

import { Place } from '../../../entities/place.model';

export type PlacesState = {
  places: Array<Place>;
  placeSelected: Place | undefined;
  loading: boolean;
  error: string | undefined;
};

export const placesInitialState: PlacesState = {
  error: '',
  loading: false,
  placeSelected: undefined,
  places: [],
};
Enter fullscreen mode Exit fullscreen mode

Perfect! We have the initial state for places. It's time to create actions!

Create Actions

Similar to our previous post, we will use the createActionGroup feature to group our actions related to the PlaceState. We have two types of actions: actions triggered by the user in the UI and actions related to the API process. Therefore, I prefer to split the actions into two types: PlacePageActions and PlaceApiActions.

First, we define actions from the PlacePage to perform the following:

  • Request to load the places: load Places

  • Add/Update Place: add a place with a Place object

  • Delete place: Send the place ID to remove

  • Select and UnSelect Place.

The places.actions.ts file code looks like this:

import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Place } from '../../../entities/place.model';

export const PlacesPageActions = createActionGroup({
  source: 'Places',
  events: {
    'Load Places': emptyProps(),
    'Add Place': props<{ place: Place }>(),
    'Update Place': props<{ place: Place }>(),
    'Delete Place': props<{ id: string }>(),
    'Select Place': props<{ place: Place }>(),
    'UnSelect Place': emptyProps(),
  },
});
Enter fullscreen mode Exit fullscreen mode

We have actions to perform from the effects. These actions are linked with the API and handle the request and response when succeeding or failing in loading, adding, updating, or deleting.

The final code looks like this:

export const PlacesApiActions = createActionGroup({
  source: 'PlaceAPI',
  events: {
    'Load Success': props<{ places: Array<Place> }>(),
    'Load Fail': props<{ message: string }>(),
    'Add Success': props<{ place: Place }>(),
    'Add Failure': props<{ message: string }>(),
    'Update Success': props<{ place: Place }>(),
    'Update Failure': props<{ message: string }>(),
    'Delete Success': props<{ id: string }>(),
    'Delete Failure': props<{ message: string }>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

We have our actions, so it's time to create the reducer to update our state!

The Reducer

The reducer is responsible for updating our state based on the actions. In some cases, we want to update our state when triggering an action or based on the result of an action in the effect.

For example, the first action loadPlaces will be triggered by the PlaceComponent. At that moment, I want to show a loading screen, so I set loading to true. In other cases, like the loadSuccess action, it will come from the effect when our PlaceService returns the data.

Implement each actions to make changes in the state as we need, I don't going to explain each one but the final code looks like:

import { createReducer, on } from '@ngrx/store';
import { placesInitialState } from './places.state';
import { PlacesApiActions, PlacesPageActions } from './places.actions';

export const placesReducer = createReducer(
  placesInitialState,
  on(PlacesPageActions.loadPlaces, (state) => ({
    ...state,
    loading: true,
  })),
  on(PlacesPageActions.selectPlace, (state, { place }) => ({
    ...state,
    placeSelected: place,
  })),
  on(PlacesPageActions.unSelectPlace, (state) => ({
    ...state,
    placeSelected: undefined,
  })),

  on(PlacesApiActions.loadSuccess, (state, { places }) => ({
    ...state,
    places: [...places],
  })),

  on(PlacesApiActions.loadFailure, (state, { message }) => ({
    ...state,
    loading: false,
    error: message,
  })),
  on(PlacesApiActions.addSuccess, (state, { place }) => ({
    ...state,
    loading: false,
    places: [...state.places, place],
  })),
  on(PlacesApiActions.addFailure, (state, { message }) => ({
    ...state,
    loading: false,
    message,
  })),
  on(PlacesApiActions.updateSuccess, (state, { place }) => ({
    ...state,
    loading: false,
    placeSelected: undefined,
    places: state.places.map((p) => (p.id === place.id ? place : p)),
  })),
  on(PlacesApiActions.updateFailure, (state, { message }) => ({
    ...state,
    loading: false,
    message,
  })),
  on(PlacesApiActions.deleteSuccess, (state, { id }) => ({
    ...state,
    loading: false,
    placeSelected: undefined,
    places: [...state.places.filter((p) => p.id !== id)],
  })),
  on(PlacesApiActions.deleteFailure, (state, { message }) => ({
    ...state,
    loading: false,
    message,
  })),
);
Enter fullscreen mode Exit fullscreen mode

We have our reducer ready to react to the actions. Now comes the exciting part: the effects! Let's do it!

The Effect

The effect listens for actions, performs async tasks, and dispatches other actions. It's important to know which RxJS operator to use—concatMap, exhaustMap, mergeMap, or switchMap—to avoid causing race conditions in your code.

What is a Race Condition?

A race condition occurs when multiple asynchronous tasks (observables) run at the same time and interfere with each other in ways we don't want. This can cause unexpected behavior or bugs because the timing of these tasks is not controlled well.

exhaustMap: Ignores new requests if there's already one in progress.

concatMap: Processes each request one after another, in order.

mergeMap: Allows all requests to run concurrently, but you need to handle the responses properly to avoid issues.

switchMap: Cancels the previous request if a new one comes in. This ensures only the latest request is processed.

Let's use each one in our effect!

If you want to learn more about these operators I highly recommend checkout @decodedfrontend videos

ExhaustMap

exhaustMap Ignores new requests if one is already in progress, useful for scenarios where only the first request should be processed, and subsequent ones should be ignored until the first completes. For example get the list of places.

import { Actions, createEffect, ofType } from '@ngrx/effects';
import { inject } from '@angular/core';
import { PlacesService } from '../../../services/places.service';
import { PlacesApiActions, PlacesPageActions } from './places.actions';
import { catchError, exhaustMap, map, of } from 'rxjs';

export const loadPlacesEffect = createEffect(
  (actions$ = inject(Actions), placesService = inject(PlacesService)) => {
    return actions$.pipe(
      ofType(PlacesPageActions.loadPlaces),
      exhaustMap(() =>
        placesService.getAll().pipe(
          map((places) => PlacesApiActions.loadSuccess({ places })),
          catchError((error) =>
            of(PlacesApiActions.loadFailure({ message: error })),
          ),
        ),
      ),
    );
  },
  { functional: true },
);
Enter fullscreen mode Exit fullscreen mode

ConcatMap

concatMap helps us map actions and merge our observables into a single observable in order, but it waits for each one to complete before continuing with the next one. It's the safest operator when we want to ensure everything goes in the declared order. For example, if we are updating a place, we want the first update to complete before triggering another.

export const updatePlaceEffect$ = createEffect(
  (actions$ = inject(Actions), placesService = inject(PlacesService)) => {
    return actions$.pipe(
      ofType(PlacesPageActions.addPlace),
      concatMap(({ place }) =>
        placesService.update(place).pipe(
          map((apiPlace) =>
            PlacesApiActions.updateSuccess({ place: apiPlace }),
          ),
          catchError((error) =>
            of(PlacesApiActions.updateFailure({ message: error })),
          ),
        ),
      ),
    );
  },
  { functional: true },
);
Enter fullscreen mode Exit fullscreen mode

MergeMap

The mergeMap operator runs fast without maintaining the order. It allows all requests to run concurrently, but we need to handle the responses properly to avoid race conditions. It is perfect for add and delete actions.

The Add Effect

export const addPlacesEffect$ = createEffect(
  (actions$ = inject(Actions), placesService = inject(PlacesService)) => {
    return actions$.pipe(
      ofType(PlacesPageActions.addPlace),
      mergeMap(({ place }) =>
        placesService.add(place).pipe(
          map((apiPlace) => PlacesApiActions.addSuccess({ place: apiPlace })),
          catchError((error) =>
            of(PlacesApiActions.addFailure({ message: error })),
          ),
        ),
      ),
    );
  },
  { functional: true },
);
Enter fullscreen mode Exit fullscreen mode

The Delete Effect

I have two effect for the deleteAction it trigger the deleteSuccess action to update the state.


export const deletePlaceEffect$ = createEffect(
  (actions$ = inject(Actions), placesService = inject(PlacesService)) => {
    return actions$.pipe(
      ofType(PlacesPageActions.deletePlace),
      mergeMap(({ id }) =>
        placesService.delete(id).pipe(
          map((id_response) =>
            PlacesApiActions.deleteSuccess({ id: id_response }),
          ),
          catchError((error) =>
            of(PlacesApiActions.deleteFailure({ message: error })),
          ),
        ),
      ),
    );
  },
  { functional: true },
);
Enter fullscreen mode Exit fullscreen mode

But I need to get the data again when the user remove item, so I need to listen deleteSuccess action, to get the data again and refresh the state.

export const deletePlaceSuccessEffect$ = createEffect(
  (actions$ = inject(Actions), placesService = inject(PlacesService)) => {
    return actions$.pipe(
      ofType(PlacesApiActions.deleteSuccess),
      mergeMap(() =>
        placesService.getAll().pipe(
          map((places) => PlacesApiActions.loadSuccess({ places })),
          catchError((error) =>
            of(PlacesApiActions.loadFailure({ message: error.message })),
          ),
        ),
      ),
    );
  },
  { functional: true },
);
Enter fullscreen mode Exit fullscreen mode

What About SwitchMap?

switchMap Use when you want to ensure that only the latest request is processed, and previous requests are canceled. Ideal for scenarios like autocomplete or live search. it helps to cancel active observables with a new one. In my scenario i don't have a case but is important to mention to take care.

Register Effects and Reducer

Its time to register my new state placesReducer and placesEffects, open the the app.config.ts,

  • Add new key in the provideStore, places: placesReducer

  • Import all effects from places.effects.ts , add to provideEffects function.

import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideStore } from '@ngrx/store';
import { homeReducer } from './pages/home/state/home.reducer';
import { placesReducer } from './pages/places/state/places.reducer';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authorizationInterceptor } from './interceptors/authorization.interceptor';
import { provideEffects } from '@ngrx/effects';
import * as homeEffects from './pages/home/state/home.effects';
import * as placesEffects from './pages/places/state/places.effects';

export const appConfig = {
  providers: [
    provideRouter(routes),
    provideStore({
      home: homeReducer,
      places: placesReducer, //register placesReducer
    }),
    provideStoreDevtools({
      name: 'nba-app',
      maxAge: 30,
      trace: true,
      connectInZone: true,
    }),
    provideEffects([homeEffects, placesEffects]), //the placesEffects
    provideAnimationsAsync(),
    provideHttpClient(withInterceptors([authorizationInterceptor])),
  ],
};
Enter fullscreen mode Exit fullscreen mode

Mm... ok, we have effect but how we get the data in the component ? I must to create a selector to get the state, let's do it!

Selectors

Create the file places.selector.ts, using the createFeatureSelector function, use the PlaceState type and define a name.

const selectPlaceState = createFeatureSelector<PlacesState>('places');
Enter fullscreen mode Exit fullscreen mode

Next, using the selectPlaceState, use the createSelector function to create selector for part in our state, like places, loading, error and activePlace and export those selector consume in the component.

The code looks like:

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { PlacesState } from './places.state';

const selectPlaceState = createFeatureSelector<PlacesState>('places');

const selectPlaces = createSelector(
  selectPlaceState,
  (placeState) => placeState.places,
);

const selectPlaceSelected = createSelector(
  selectPlaceState,
  (placeState) => placeState.placeSelected,
);
const selectLoading = createSelector(
  selectPlaceState,
  (placeState) => placeState.loading,
);
const selectError = createSelector(
  selectPlaceState,
  (placeState) => placeState.error,
);

export default {
  placesSelector: selectPlaces,
  selectPlaceSelected: selectPlaceSelected,
  loadingSelector: selectLoading,
  errorSelector: selectError,
};
Enter fullscreen mode Exit fullscreen mode

Using Actions and Selectors

The final steps is use the selectors and dispatch actions from the components, open places.component.ts , inject the store. Next, declare place$, error$ and placeSelected$ to store the value from the selectors.


  store = inject(Store);
  places$ = this.store.select(PlacesSelectors.placesSelector);
  error$ = this.store.select(PlacesSelectors.errorSelector);
  placeSelected$ = this.store.select(PlacesSelectors.selectPlaceSelected);
Enter fullscreen mode Exit fullscreen mode

We need to display the places, in my case i have the component place-card and place-form.

  • place-card: show information about the place and allow select and remove.

  • place-form: allow to rename the place.

Let's to work on it!

The PlaceCard

Its get the place as input property and dispatch two actions, delete and select.

import { Component, inject, input } from '@angular/core';
import { Place } from '../../entities/place.model';
import { Store } from '@ngrx/store';
import { PlacesPageActions } from '../../pages/places/state/places.actions';

@Component({
  selector: 'app-place-card',
  standalone: true,
  imports: [],
  templateUrl: './place-card.component.html',
  styleUrl: './place-card.component.scss',
})
export class PlaceCardComponent {
  place = input.required<Place>();
  store = inject(Store);

  edit() {
    this.store.dispatch(PlacesPageActions.selectPlace({ place: this.place() }));
  }

  remove() {
    this.store.dispatch(PlacesPageActions.deletePlace({ id: this.place().id }));
  }
}
Enter fullscreen mode Exit fullscreen mode

In the template bind the place input and add two buttons to call the method edit and remove.

The html markup looks like:

<div class="column">
  <div class="card place-card">
    <div class="card-image">
      <figure class="image is-4by3">
        <img [alt]="place().description" [src]="place().avatar">
      </figure>
    </div>
    <div class="card-content">
      <div class="media">
        <div class="media-content">
          <p class="title is-4">{{ place().name }}</p>
          <p class="subtitle is-6">Visited on: <span>{{ place().createdAt }}</span></p>
        </div>
      </div>
      <div class="content">
        {{ place().description }}.
        <br>
        <strong>Price:</strong> {{ place().price }}
      </div>
      <div class="buttons">
        <button (click)="edit()" class="button is-info">Change Name</button>
        <button (click)="remove()" class="button is-danger">Remove</button>
      </div>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Let's continue with the place-form, it is a bit different because take the selectedPlace from state and dispatch the update action.

import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { FormsModule } from '@angular/forms';
import { AsyncPipe, JsonPipe } from '@angular/common';
import PlacesSelectors from '../../pages/places/state/places.selectors';
import { PlacesPageActions } from '../../pages/places/state/places.actions';
import { Place } from '../../entities/place.model';

@Component({
  selector: 'app-place-form',
  standalone: true,
  imports: [FormsModule, JsonPipe, AsyncPipe],
  templateUrl: './place-form.component.html',
  styleUrl: './place-form.component.scss',
})
export class PlaceFormComponent {
  store = inject(Store);
  placeSelected$ = this.store.select(PlacesSelectors.selectPlaceSelected);

  delete(id: string) {
    this.store.dispatch(PlacesPageActions.deletePlace({ id }));
  }

  save(place: Place, name: string) {
    this.store.dispatch(
      PlacesPageActions.updatePlace({
        place: {
          ...place,
          name,
        },
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In the template, subscribe to selectedPlace and to make it easy and keep the article short, add a input bind the selectedPlace.name, using template variables create a reference to the inputname #placeName.

In the Save button click, we pass the selectedPlace and the new name.

I can to it a bit better, but the article is become a bit longer, if you know a better way leave a comment with your solution or send a PR and for sure I will add your code and mention on it!.

@if (placeSelected$ | async; as selectedPlace) {

  <div class="field">
    <label class="label" for="placeName">Place Name</label>
    <div class="control">
      <input id="placeName" #placeName [value]="selectedPlace.name" class="input" placeholder="Place Name" type="text">
    </div>
  </div>

  <div class="field is-grouped">
    <div class="control">
      <button (click)="save(selectedPlace, placeName.value)" class="button is-primary">Save</button>
    </div>
    <div class="control">
      <button (click)="delete(selectedPlace.id)" class="button is-danger">Delete</button>
    </div>
  </div>

}
Enter fullscreen mode Exit fullscreen mode

Finally in the places.component I going to use the place-card and place-form in combination with the selectors.

We going to make three key things:

  • subscribe to error$ to show if got one.

  • subscribe to places and combine with @for to show places.

  • suscribe to placeSelected$ to show a modal when the user select one and trigger onClose() method when click in close button.

The final code looks like:

@if (error$ | async; as error) {
  <h3>Ups we found a error {{ error }}</h3>
}

@if (places$ | async; as places) {
  <div class="columns is-multiline is-flex is-flex-wrap-wrap">
    @for (place of places; track place) {
      <app-place-card [place]="place"/>
    }
  </div>
}

@if (placeSelected$ | async; as selectedPlace) {
  <div class="modal is-active">
    <div class="modal-background"></div>
    <div class="modal-content">
      <app-place-form></app-place-form>
    </div>
    <button (click)="onClose()" class="modal-close is-large" aria-label="close"></button>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this project, we explored how to create a CRUD application using NgRx and various RxJS operators to handle asynchronous actions. We also practiced managing HTTP requests and responses from an API. However, I feel there is room for improvement.

Points to Improve:

  • The need to dispatch actions when navigating to the /places component route.

  • The excessive amount of code required for a simple CRUD operation.

To address these issues, I plan to refactor the project by combining NgRx Entities with the NgRx Router to streamline the code and improve maintainability.

Resources:

Top comments (1)

Collapse
 
jangelodev profile image
João Angelo

Hi Dany Paredes,
Top, very nice and helpful !
Thanks for sharing.