When to Use concatMap, mergeMap, switchMap or 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 do it!
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
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 theplaces
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: [],
};
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(),
},
});
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 }>(),
},
});
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,
})),
);
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.
So is important to know the following map operators, not cause race conditions in your code.:
exhaustMap: Ignores new requests if there's already one in progress. ⚠️it cause race conditions if the ignored requests are important, as they are simply dropped and never executed.
mergeMap: Allows all requests to run concurrently, but you need to handle the responses properly to avoid issues. ⚠️ it cause race conditions because multiple requests are processed in parallel, which can lead to unexpected results if responses come back in a different order or if shared resources are modified concurrently.
concatMap: it takes each request one after another, in order. It is the safest operator and doesn't cause race conditions, so it safe ✅ without race conditions as requests are queued and handled sequentially.
switchMap: Cancels the previous request if a new one comes in. This ensures only the latest request is processed. so ✅ No race conditions as only one request is active at any time and previous requests are cancelled and do not affect the current processing.
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 },
);
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 },
);
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 },
);
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 },
);
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 },
);
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 toprovideEffects
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])),
],
};
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');
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,
};
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);
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 }));
}
}
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>
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,
},
}),
);
}
}
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>
}
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>
}
Conclusion
In this article, we learned 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:
Why 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:
Subscribe to my newsletter
Read articles from Dany Paredes directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Dany Paredes
Dany Paredes
I'm passionate about front-end development, specializing in building UI libraries and working with technologies like Angular, NgRx, Accessibility, and micro-frontends. In my free time, I enjoy writing content for the Google Dev Library, This Is Angular Community, Kendo UI, and sharing insights here.