DEV Community

loading...
Cover image for Working with NgRx Effects
This Dot

Working with NgRx Effects

Chris Trześniewski
Originally published at labs.thisdot.co Updated on ・7 min read

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
Enter fullscreen mode Exit fullscreen mode

After cloning, just install all the dependencies.

yarn install
Enter fullscreen mode Exit fullscreen mode

and you can see the example app by running

yarn start -o
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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 photos
PUT /api/photos/:photoId/like - returns the photo that was liked
PUT /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');
Enter fullscreen mode Exit fullscreen mode

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
  }), {}))
);
Enter fullscreen mode Exit fullscreen mode

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,
        }
      ]
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

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:

Effects 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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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 likePhotoand 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');
Enter fullscreen mode Exit fullscreen mode

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
  }), {}))
);
Enter fullscreen mode Exit fullscreen mode

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),
    mergeMap(({id}) => this.photoService.likePhoto(id).pipe(
      map(photo => updatePhotoSuccess({photo})),
      catchError(() => [updatePhotoError()])
    ))
  ));

  dislikePhoto$ = createEffect(() => this.actions$.pipe(
    ofType(dislikePhoto),
    mergeMap(({id}) => this.photoService.dislikePhoto(id).pipe(
      map(photo => updatePhotoSuccess({photo})),
      catchError(() => [updatePhotoError()])
    ))
  ));

  /* constructor */
}
Enter fullscreen mode Exit fullscreen mode

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:

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.

Discussion (0)