Effectively managing side-effects in Angular with @ngrx/effects

In this article I’ll explain where side-effects fit into the bigger picture of your app architecture. Then I’ll explain how @ngrx/effects can be used to make this architecture work. I’ll also show examples of using @ngrx/effects, including how to test them. Finally I’ll talk about trade-offs and some thoughts on further abstractions. I originally wrote this as a way to explain @ngrx/effects to the rest of the frontend team at work so I’m making some assumptions about what you already know.

Assumptions I’m making about you:


Separating view logic from side-effects #

Pure functions are helpful for managing complexity. They greatly help in eliminating time-dependent bugs and limit the knowledge necessary to understand how a chunk of code works. “With this input, this is the output. Ah that’s how this works, got it.??” An app’s UI should ideally be modeled as a pure function of state, const UI = (state) => viewFn(state). Well, in Angular this isn’t truly pure because there’s no separation between the template output by a component and the side-effect of writing to the DOM. Cycle.js models the UI as a legit pure function, but I digress.

Rhetorical question time. If everything is a pure function, how do you send requests to your backend API, interact with offline storage, upload images, etc? (state) => viewFn(state) doesn’t have any affordances for these side-effectful behaviors.

Side-effects aren’t evil. Any useful app needs to perform side-effects by definition. However, in order to keep the benefits of having a pure view function, side-effects should be handled separately from the core UI logic. This idea leads to splitting our app into two explicit layers; the pure view function that makes up the core logic of building our UI, and a layer around this core where we can perform side-effects and feed updated state back into the pure view function. With this model the UI can look to the app state as the “truth of the world” without having to be aware of how or even if the impure side-effects are happening. Sounds like some nice loose coupling to me.

So we want a pure layer and a side-effect layer, great… now how do we pass information between the layers? The view layer needs to somehow indicate to the side-effect layer that it wants to pass off some side-effectful work. The side-effect layer needs to listen for this communication, do the work, and then send back updated state to the view layer.

One solution is to use redux actions as the contract connecting the layers. Actions describing the side-effect can be dispatched by the view layer in reaction to asynchronous events like button clicks. The side-effect layer in turn listens for these actions, performs the side-effectful work, and then dispatches an action with new state info in the payload. This solution allows us to successfully separate pure view code from side-effectful work and gives us a strong contract for communicating between the two.

Using @ngrx/effects as the side-effect layer #

@ngrx/effects is a small library that works together with @ngrx/store and allows us to listen to actions being dispatched to the store. Specifically, it gives us an observable that emits all actions that are dispatched to @ngrx/store and also handles dispatching actions after doing the side-effect.

Let’s break down the exact requirements for our side-effects layer.

  1. Needs to be able to listen for specific actions emitted by the view
  2. Needs to be able to send actions back to the store to update the view’s state after completing side-effectful work
  3. Needs to wrap our view layer in a way that it’s not tightly coupled to every component

Listening to actions from the view layer #

Listening for actions is taken care of with a handy actions$ observable that @ngrx/effects exports. By providing Actions to an Angular service, we’ll have access to this actions$. We can use all the normal observable operators to manipulate the actions$. If we want to listen to a specific action, we can use the .filter operator. actions$.filter((action) => action.type === 'INTERESTING_ACTION') But wait, there’s a better way! Since filtering for actions is so common, @ngrx/effects is nice enough to include the .ofType operator on actions$ to save us some typing. actions$.ofType('INTERESTING_ACTION') is equivalent to our .filter. It even allows us to pass multiple actions to watch for any of them with actions$.ofType('ACTION_1', 'ACTION_2').

import { Injectable } from '@angular/core';
import { Actions } from '@ngrx/effects';

@Injectable()
export class DummyEffects {
  constructor(private actions$: Actions) {
    // NOTE: this is just for demonstration.. you won't ever need to explicitly subscribe to action$, read on to see what I mean.
    this.actions$.subscribe((action) => {
      // every action that gets dispatched will be emitted by this observable
      console.log('Behold, an action!', action);
    });
    this.actions$.ofType('INTERESTING_ACTION').subscribe((action) => {
      // only actions that match the type will be emitted here
      console.log('Behold, an interesting action!', action);
    });
  }
}

Sending actions back to the view layer #

Sending back actions to the store is pretty straightforward too. @ngrx/effects exports an @Effect decorator. Adding the decorator to a stream of actions on a service will hint to @ngrx/effects that the emitted actions should be dispatched to @ngrx/store.

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';

@Injectable()
export class DummyEffects {
  constructor(private actions$: Actions) {}

  // decorator to hint to @ngrx/effects that it should dispatch any emitted actions to the @ngrx/store
  @Effect()
  effects$ = this.actions$
    .ofType('INTERESTING_ACTION')
    // do some side-effectful work
    .flatMap((action) => somePretendHttpRequest(action.payload))
    // emit an action with some payload that will update state
    .map((response) => {
      return { type: 'INTERESTING_ACTION_SUCCESS', payload: response };
    });
}

Notice how there is no .subscribe to be found with these action streams. @ngrx/effects manages subscribing to these streams for us instead. With this we can now dispatch actions after finishing side-effects. These actions will be handled by reducers to update the view state. The view doesn’t need to bother itself with how that state got there.

If you want to create an effect that doesn’t dispatch any actions, you can set
the dispatch option to false in the @Effect decorator. One example use case for this is to send a tracking request since there’s no state that needs to be updated afterwards.

@Effect({ dispatch: false })
effect$ = this.action$
  .ofType('INTERESTING_ACTION')
  .do(() => sendTrackRequest())

If you need to access the current store state in your effect, you can provide @ngrx/store to your service and use .withLatestFrom.

@Effect()
effect$ = this.action$
  .ofType('INTERESTING_ACTION')
  .withLatestFrom(this.store.select('state-slice'))
  .map(([action, state]) => { ... })

Wrap the view layer without tight coupling #

I’ve so far hand waved away how exactly @ngrx/effects knows to listen and dispatch to the store. Notice we never import anything from @ngrx/store in the DummyEffects service nor have we tied the DummyEffects service to the app in any way. We need to kickoff the effects by subscribing them to the app store somehow. To do this, @ngrx/effects exports EffectsModule which has a .run method that takes a service with @Effect() decorated action streams on it.

import { EffectsModule } from '@ngrx/effects';
import { DummyEffects } from './path/to/dummy-effects.service';

@NgModule({
  imports: [
    // subscribes @Effect() decorated action streams to the app's @ngrx/store
    EffectsModule.run(DummyEffects),
  ],
  ...
})
export class AppModule {}

This is the one and only place we need to tie an effects service and an app together. No need to import effects services into each component that wants to dispatch a side-effect describing action. Now any time an INTERESTING_ACTION is dispatched, from any place within the app, the DummyEffects service will perform its side-effectful work and then dispatch its actions. Every time you want to add a new side-effect service, just add another EffectsModule.run(NewSideEffectService) line to the app module. Ezpz.

Note that you can add @Effect decorators to any service you want and then register it with EffectsModule.run. This means you don’t need to make an entire new service every time you need to add an effect. You can provide Actions and add @Effect decorators to an existing service and then import EffectsModule.run(ExistingService) in the app module and effects on the service will run. This flexibility has made it easy to incrementally adopt @ngrx/effects in DroneDeploy’s app.

There are also more advanced ways of adding and removing effects. You can check out the @ngrx/effects api docs to see the other possibilities.

Testing @ngrx/effects #

The goal with testing effects is to ensure that dispatching the correct actions will cause the effect to run and then dispatch the correct actions back to the store. Basically, to test whether the view and side-effect layers follow the expected contract. It might also make sense to test whether calls to network request services are made correctly.

@ngrx/effects provides great testing helpers to make this painless. Let’s setup a service that makes an http request to update user info.

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { UserApi } from './user-api.service';
import { Observable } from 'rxjs';

@Injectable()
export class UserEffects {
  constructor(
    private actions$: Actions,
    private userApi: UserApi,
  ) {}

  @Effect()
  effects$ = this.actions$
    .ofType('UPDATE_USER')
    .switchMap((action) => this.userApi.post(action.payload))
      .map((response) => {
        return { type: 'UPDATE_USER_SUCCESS', payload: response };
      })
      .catch((err) => {
        return Observable.of({ type: 'UPDATE_USER_FAIL', payload: err });
      });
}

Now to test this service.

import { EffectsRunner, EffectsTestingModule } from '@ngrx/effects/testing';
// import other stuff

describe('UserEffects', () => {
  let userApi;
  let service;
  let effectsRunner;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        EffectsTestingModule,
      ],
      providers: [
        UserApi,
        DummyEffects,
        provideStore({ userReducer }),
        EffectsRunner,
      ],
    });

    userApi = TestBed.get(UserApi);
    service = TestBed.get(UserEffects);
    effectsRunner = TestBed.get(EffectsRunner);
  });

  describe('when an UPDATE_USER is dispatched', () => {
    it('should dispatch UPDATE_USER_SUCCESS if successful', () => {
      const fakeUserApiSuccess = () => {
        return Observable.create((observer) => {
          observer.next('great success');
        });
      };
      spyOn(userApi, 'post').and.callFake(fakeUserApiSuccess);
      // effectsRunner.queue queues up actions in the store to get your effects to run
      effectsRunner.queue({ type: 'UPDATE_USER' });
      service.effects$.take(1).subscribe((action) => {
        expect(action).toEqual({
          type: 'UPDATE_USER_SUCCESS',
          payload: 'great success'
        });
      })
    });

    it('should call userApi.post with the correct parameters', () => {
      const fakeUserApiSuccess = () => {
        return Observable.create((observer) => {
          observer.next('great success');
        });
      };
      spyOn(userApi, 'post').and.callFake(fakeUserApiSuccess);
      effectsRunner.queue({
        type: 'UPDATE_USER',
        payload: 'updated info'
      });
      service.effects$.take(1).subscribe((action) => {
        expect(userApi.post).toHaveBeenCalledWith('updated info');
      });
    });

    it('should dispatch INTERESTING_ACTION_FAILED if failed', () => {
      const fakeUserApiFail = () => {
        return Observable.create((observer) => {
          observer.error('error');
        });
      };
      spyOn(userApi, 'post').and.callFake(fakeUserApiFail);
      effectsRunner.queue({ type: 'UPDATE_USER' });
      service.effects$.take(1).subscribe((action) => {
        expect(action).toEqual({
          type: 'UPDATE_USER_FAIL',
          payload: 'error'
        });
      });
    });
  });
});

When not to use @ngrx/effects #

Surprise, there are trade-offs to this approach. Since the view and side-effects layer communicate via redux actions, their payloads must be serializable. If actions are not serializable, it becomes difficult, if not impossible, to replay actions to time travel across state updates.

If you need to deal with non-serializable entities, like files, in order to perform a side-effect, @ngrx/effects by itself is probably not the right strategy. Instead, you could use a normal Angular service that manually subscribes to @ngrx/store and exposes public methods that can be called to perform side-effects and dispatch actions to the store. This was our go-to strategy for side-effects in DroneDeploy’s app before introducing @ngrx/effects.

import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { UploadFileApi } from './upload-file-api.service';
import { Observable } from 'rxjs';

@Injectable()
export class UploadFiles {
  constructor(
    private store: Store<any>,
    private uploadFileApi: UploadFileApi,
  ) {}

  public upload(unserializableFile) {
    const successfulUpload = (response) => {
      this.store.dispatch({
        type: 'UPLOAD_SUCCESS',
        payload: response
      });
    };
    const failedUpload = (error) => {
        this.store.dispatch({
          type: 'UPLOAD_FAILED',
          payload: error
        });
    };
    this.uploadFileApi.post(unserializableFile)
      .take(1)
      .subscribe(successfulUpload, failedUpload);
  }
}

Some hazy thoughts on further abstractions and use cases #

It’d be neat to run all side-effects in a web worker instead of on the main UI thread. Imagine having all network requests be handled by @ngrx/effects inside a web worker. Only passing action objects back and forth between the web worker and the main thread seems doable but you could run into perf issues if you also need to pass every updated store state to the web worker. There’s an open issue about this on @ngrx/effect’s repo if you want to discuss it.

You could build an effect used for sending detailed bug reports. The service would watch for any failure actions being dispatched and then send a logging request with the n previous actions leading up to the error.

 
10
Kudos
 
10
Kudos