Working with NgRx Effects
Almost every web application will, at some point, need to interact with some external resources. The most classic solution to that would be a service-based approach where components are calling and interacting with the external resources directly through services. In this case, most of the heavy lifting is delegated to the services and a component in this scenario still carries a responsibility to directly initiate those interactions.
NgRx Effects provides us with a way to isolate interactions, with the aforementioned services, from the components. Within Effects, we can manage various tasks ie. communication with the API, long-running tasks, and practically every other external interaction. In this scenario, the component doesn't need to know about these interactions at all. Its only requires some input data and then emits simple events (actions).
In this article, we will build on top of the application we started in Introduction to NgRx. You can find the entry point for this article on my GitHub repo. If you want to follow this article's code, please clone the repository and checkout the effects_entryPoint
tag.
git clone git@github.com:ktrz/introduction-to-ngrx.git
git checkout effects_entryPoint
After cloning, just install all the dependencies.
yarn install
and you can see the example app by running
yarn start -o
Getting started
In order to add NgRx Effects to our application, all we need to do is use the ng add
functionality offered by the Angular CLI. Run the following command:
ng add @ngrx/effects@latest
It will add and install the @ngrx/effects
library to your package.json
and scaffold your AppModule
to import the NgRx EffectsModule
into your application.
This is the code that the Angular CLI will generate for you:
/* Other imports */
import { EffectsModule } from '@ngrx/effects';
@NgModule({
declarations: [
AppComponent
],
imports: [
/* Other imported modules */
EffectsModule.forRoot([]),
],
bootstrap: [AppComponent]
})
export class AppModule {
}
With the setup complete, we can start modifying the app to introduce and handle some API calls using Effects.
Design interactions - Actions & Reducers
When you're designing new features, I highly encouarge you to first create the actions which we expect to see in the application. Let's look at the example API, which you can clone and checkout: effects_ready
branch from this repo. Then, use the npm start
command to run it locally.
The API consists of the following endpoints:
GET /api/photos
- returns an array of photosPUT /api/photos/:photoId/like
- returns the photo that was likedPUT /api/photos/:photoId/dislike
- returns photo that was disliked
We can start designing our app interactions by handling how the list of photos is loaded. First, we'll need a trigger action to start fetching the list of photos. Since the request can either return successfully, or with an error, let's model that as well within the actions:
// src/app/store/photo.actions.ts
import {createAction, props} from '@ngrx/store';
import {Photo} from '../photo/photo';
export const loadPhotos = createAction('[Photo List] Load Photos');
export const loadPhotosSuccess = createAction('[Photo List] Load Photos Success', props<{photos: Photo[]}>());
export const loadPhotosError = createAction('[Photo List] Load Photos Error');
We have modeled the actions that might occur in the application. Now it's time to handle them properly in the photo.reducer.ts
.
// src/app/store/photo.reducer.ts
/* other imports */
import {loadPhotosSuccess} from './photo.actions';
const initialState: PhotoState = {};
export const photoReducer = createReducer(
initialState,
/* previous `on` handlers */
on(loadPhotosSuccess, (state, {photos}) => photos.reduce((acc, photo) => ({
...acc,
[photo.id]: photo
}), {}))
);
Since we're getting an array of photos, and we're keeping them in the state as an id-indexed map, we just need to transform it into the appropriate shape. Since we assume that the API returns all of the photos, we can replace the whole previous state.
Great! We now have a correctly working reducer. However, we don't actually emit any action that will put the data in our Store anywhere in our application. To verify that it works correctly, we can dispatch loadPhotosSuccess
action in our AppComponent
:
// src/app/app.component.ts
export class AppComponent implements OnInit {
photos$ = this.store.select(selectPhotos);
constructor(private store: Store<AppState>) {
}
ngOnInit(): void {
this.store.dispatch(loadPhotosSuccess({
photos: [
{
id: '2d335401-d65e-4059-b8f0-a4816c82086f',
title: 'Introduction to NgRx',
url: 'https://ngrx.io/assets/images/ngrx-badge.png',
likes: 0,
dislikes: 0,
},
{
id: '65a7eb36-f887-4a93-8fe7-38d20c77906f',
title: 'Angular',
url: 'https://angular.io/assets/images/logos/angular/angular.png',
likes: 0,
dislikes: 0,
}
]
}));
}
}
The data is loaded correctly and all the other functionality is still working as expected. Let's revert this dispatch so we can finally create our Effects, which will allow our available photos to asynchronously load.
Create Effects
In NgRx, Effects are encapsulated in a regular Angular Injectable
class. To let NgRx know to use our class as Effects, we need to add an EffectsModule.forRoot([])
array inside of our AppModule
imports:
// src/app/store/photo.effects.ts
import {Actions} from '@ngrx/effects';
import {PhotoService} from '../api/photo.service';
import {Injectable} from '@angular/core';
@Injectable()
export class PhotoEffects {
constructor(
private actions$: Actions, // this is an RxJS stream of all actions
private photoService: PhotoService // we will need this service for API calls
) {}
}
// src/app/app.module.ts
@NgModule({
declarations: [
AppComponent
],
imports: [
/ * other imports */
EffectsModule.forRoot([PhotoEffects]),
// this is necessary for `PhotoService` to have access to the HttpClient
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
Inside of the PhotoEffects
, we will create properties that will react to specific actions being dispatched, perform some side effect (in this case an API call), and susequently dispatch another action based on the API call result. This flow is presented in the following diagram:
In our case, we will listen for the loadPhotos
action being dispatched. Then, we will call the PhotoService -> getPhotos()
method, which should either return the correct data, or return an error (ie. a network error). Upon receiving data, we can dispatch the loadPhotosSuccess
action, and in order to handle possible errors, we might dispatch loadPhotosError
:
// src/app/store/photo.effects.ts
import {Actions, createEffect, Effect, ofType} from '@ngrx/effects';
import {PhotoService} from '../api/photo.service';
import {Injectable} from '@angular/core';
import {loadPhotos, loadPhotosError, loadPhotosSuccess} from './photo.actions';
import {catchError, map, switchMap} from 'rxjs/operators';
import {of} from 'rxjs';
@Injectable()
export class PhotoEffects {
loadPhotos$ = createEffect(() =>
this.actions$.pipe(
ofType(loadPhotos),
switchMap(() => this.photoService.getPhotos().pipe(
map(photos => loadPhotosSuccess({photos})),
catchError(() => of(loadPhotosError()))
))
)
);
constructor(
private actions$: Actions,
private photoService: PhotoService
) {}
}
The app still doesn't do anything. That's because we need the loadPhotos
action to be dispatched somewhere. We can do it on the AppComponent
initialization inside of ngOnInit
lifecycle hook.
// src/app/app.component.ts
export class AppComponent implements OnInit {
/* Rest of the component */
constructor(private store: Store<AppState>) {}
ngOnInit(): void {
this.store.dispatch(loadPhotos());
}
/* Rest of the component */
}
If we look at our application again, we can see that the correct data has loaded. In the network tab of the Dev Tools, we can see the correct API being called. Liking/disliking still works, at least until we refresh the page. We still don't perform any API calls when we like or dislike a photo. Let's implement that behavior similarly to how we implemented photo loading.
The easiest way to accomplish this is by treating the likePhoto
and dislikePhoto
actions as triggers for the API call, and upon a successful or failed response, emitting a new action. Let's name those updatePhotoSuccess
and updatePhotoError
:
// src/app/store/photo.actions.ts
import {createAction, props} from '@ngrx/store';
import {Photo} from '../photo/photo';
/* other actions */
export const updatePhotoSuccess = createAction('[Photo List] Update Photo Success', props<{photo: Photo}>());
export const updatePhotoError = createAction('[Photo List] Update Photo Error');
Now, in reducer, instead of having separate handling for like and dislike, we can replace it with a single handler for updatePhotoSuccess
// src/app/store/photo.reducer.ts
/* Rest of the file */
const initialState: PhotoState = {};
export const photoReducer = createReducer(
initialState,
on(updatePhotoSuccess, (state, {photo}) => ({
...state,
[photo.id]: photo
})),
on(loadPhotosSuccess, (state, {photos}) => photos.reduce((acc, photo) => ({
...acc,
[photo.id]: photo
}), {}))
);
Now, with all actions and reducers in place, all that is left to do is add a new effect responsible for performing API call and emitting a new action for updating the state.
@Injectable()
export class PhotoEffects {
/ * loadPhotos$ effect */
likePhoto$ = createEffect(() => this.actions$.pipe(
ofType(likePhoto),
switchMap(({id}) => this.photoService.likePhoto(id).pipe(
map(photo => updatePhotoSuccess({photo})),
catchError(() => [updatePhotoError()])
))
));
dislikePhoto$ = createEffect(() => this.actions$.pipe(
ofType(dislikePhoto),
switchMap(({id}) => this.photoService.dislikePhoto(id).pipe(
map(photo => updatePhotoSuccess({photo})),
catchError(() => [updatePhotoError()])
))
));
/* constructor */
}
Conclusion
Now, all the functionality is still working, and our data is kept safely on the server. All of this was done without modifying the component's code (except for initial dispatch of loadPhotos
). That means we can add some complex logic for how we handle data (ie. add data polling, optimistic update, caching etc.) without requiring the components to know about this. This enables us to keep the codebase cleaner and much easier to maintain.
You can find the code for this article's end result on my GitHub repos:
- Angular app
-
Photos API appCheckout
effects_ready
tag to get the up-to-date and ready-to-run solution.
In case you have any questions you can always tweet or DM me @ktrz. I'm always happy to help!
This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.
This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.
Top comments (0)