DEV Community

Jon Rimmer
Jon Rimmer

Posted on

NgRx Sagas

All the code I produced while writing this article can be found here: https://github.com/jonrimmer/ngrx-saga

I find writing effects to be the most complex and error-prone part of building an NgRx app. For all their power, effects can be tricky to get right, especially when the logic flow becomes more complex, combining multiple asynchronous loads, and needing supporting infrastructure to dispatch ancillary actions to do things like show and hide spinners, and notify the user about errors.

Even after using RxJS almost every day for the past few years, I still encounter difficulties writing sophisticated, branching reactive flows. It's easy to get lost in a nest of switchMaps, higher-order functions, and ternary conditions, and you can find yourself yearning for the lost if-else simplicity of a plain imperative programming.

But that's just the way it is, right? Well, perhaps not. The redux-saga project provides an interesting alternative way of writing side effects for the Redux paradigm. Instead of using a reactive flow, the library leverages JavaScript's support for generators.

Check out this basic example from the redux-saga homepage:

// worker Saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
   try {
      const user = yield call(Api.fetchUser, action.payload.userId);
      yield put({type: "USER_FETCH_SUCCEEDED", user: user});
   } catch (e) {
      yield put({type: "USER_FETCH_FAILED", message: e.message});
   }
}

The use of the yield keyword allows the developer to return a value from the function at any point, which will then be processed by the library. It might be an instruction to dispatch an action, or it might be to make an asynchronous call. When the library has completed the instruction, the saga will continue from the point it left off.

Example: Initial User Load

Let's consider a NgRx scenario where we are trying to load some details about the current user, at app startup, from an API. Our requirements are:

  • We need to fetch the user's profile.
  • We need to use the id from the profile to load the user's permissions.
  • If the user has permissions, we need to load and precache a number of post entities.
  • We need to show and hide a busy spinner while the above is happening.
  • We need to catch any load errors and notify the user something went wrong.

For this example, we'll forget about the actual store state. For writing effects, we just need to know the actions. Let's define them:

export const loadUser = createAction('[User] Load user');
export const loadUserSuccess = createAction(
  '[User] Load user success',
  props<{ user: User; permissions: string[] }>()
);
export const loadPostsSuccess = createAction(
  '[Posts] Load posts success',
  props<{ posts: Post[] }>()
);
export const notifyError = createAction(
  '[Notifications] Error',
  props<{ error: any }>()
);
export const showSpinner = createAction('[Spinner] Show spinner');
export const hideSpinner = createAction('[Spinner] Hide spinner');

With our actions defined, we can write an effect to meet our requirements:

initUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUser),
    switchMap(() =>
      concat(
        of(showSpinner()),
        this.usersService.getUser().pipe(
          switchMap(user =>
            this.usersService
              .getPermissions(user.id)
              .pipe(
                switchMap(permissions =>
                  concat(
                    of(loadUserSuccess({ user, permissions })),
                    permissions.includes('posts:read')
                      ? this.postsService
                          .getPosts()
                          .pipe(map(posts => loadPostsSuccess({ posts })))
                      : EMPTY
                  )
                )
              )
          ),
          catchError(error => of(notifyError(error)))
        ),
        of(hideSpinner())
      )
    )
  )
);

Opinions will vary, but despite having written similar effects many times, I didn't find this logic especially easy to code, or to parse when I look back at it.

Perhaps we can simplify things by splitting it up into two effects, with a helper function to show our spinner and catch errors:

function task<T extends Action>(
  work: (a: T) => Observable<Action>
): (a: T) => Observable<Action> {
  return (a: T) =>
    concat(
      of(showSpinner()),
      work(a).pipe(catchError(error => of(notifyError(error)))),
      of(hideSpinner())
    );
}

initUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUser),
    switchMap(
      task(() =>
        this.usersService
          .getUser()
          .pipe(
            switchMap(user =>
              this.usersService
                .getPermissions(user.id)
                .pipe(
                  map(permissions => loadUserSuccess({ user, permissions }))
                )
            )
          )
      )
    )
  )
);

precachePosts$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUserSuccess),
    switchMap(
      task(({ permissions }) =>
        permissions.includes('posts:read')
          ? this.postsService
              .getPosts()
              .pipe(map(posts => loadPostsSuccess({ posts })))
          : EMPTY
      )
    )
  )
);

A little better... perhaps? But not by much. And it's less obvious that the two effects are intended to act in sequence as an initial load phase.

Introducing fromActionGenerator

The task() higher-order projector function in the above example demonstrates how we can take a function that does the work of the effect, mapping from an action to an observable of actions, and wrap it in additional logic. Could we use a similar approach to support a more saga-ish way of writing effects?

Let's define a new function, fromActionGenerator:

function fromActionGenerator<T extends Action>(
  // In TS 3.6 this type will change to AsyncGenerator:
  generator: (action: T) => AsyncIterableIterator<Action>
): (action: T) => Observable<Action> {
  return a1 =>
    new Observable<Action>(sub => {
      let unsubbed = false;

      (async () => {
        try {
          for await (const a of generator(a1)) {
            if (unsubbed) {
              return;
            }

            sub.next(a);
          }
        } catch (error) {
          sub.error(error);
          unsubbed = true;
        }
      })().then(() => {
        if (!unsubbed) {
          sub.complete();
        }
      });

      return () => {
        unsubbed = true;
      };
    });
}

This function accepts a generator parameter, which is a function mapping from a single action to an AsyncIterableIterator<Action>. The latter is just the type that TypeScript gives to asynchronous generator function, like this:

async function* (action: Action) {
  yield someAction();
}

Note: Unfortunately, an async generator of this kind cannot be an arrow function. For whatever reason, the TC39 committee has let the proposal to support them languish in stage 1 since 2016 with no indication it will ever progress further.

The use of async means we will be able use async/await inside our saga to wait on things like promises. This is different from the redux-saga library, where asynchronous actions require yielding a special type of object. In our design, the only thing that will be yield-able will be plain actions.

The body of fromActionGenerator function creates a new observable using the Observable constructor. The subscribe function then does the work of connecting the supplied subscriber to the generator output.

Since the generator function will be async, it must be run from within an async function, which we create and execute immediately. The async function uses for-await-of to asynchronously iterate through all the actions yielded by the function, and emits each one to the subscriber, which will be the NgRx actions observable.

When the generator function finishes, or when the observable is unsubscribed, the asynchronous iteration loop is ended, and the observable completes.

Note: The use of asynchronous generators, relies on some quite recent EcmaScript additions. Make sure to add "es2018" or newer to the lib property of your tsconfig.json file in order to enable support.

Putting it to use

Now let's rewrite our earlier effect using a saga. Because of the aforementioned inability to use an arrow function for async generators, we'll instead define it inside our effect class constructor, to get easy access to the injected services:

class AppEffects {
  constructor(
    private actions$: Actions,
    private usersService: UsersService,
    private postsService: PostsService
  ) {
    async function* loadUserSaga() {
      try {
        yield showSpinner();

        const user = await usersService.getUser().toPromise();
        const permissions = await usersService
          .getPermissions(user.id)
          .toPromise();

        yield loadUserSuccess({ user, permissions });

        if (permissions.includes('posts:read')) {
          yield loadPostsSuccess({
            posts: await postsService.getPosts().toPromise()
          });
        }
      } catch (error) {
        yield notifyError(error);
      } finally {
        yield hideSpinner();
      }
    }

    this.loadUser$ = createEffect(() =>
      this.actions$.pipe(
        ofType(loadUser),
        switchMap(fromActionGenerator(loadUserSaga))
      )
    );
  }
}

 Maybe we don't need a generator?

In redux-saga, the way to perform asynchronous work is to yield a special type of value, which the library knows to wait on before re-entering the saga function. In our case, we're using async/await to achieve the same thing. Since all we're yielding is actions, and these will be processed synchronously, it occurs that we don't actually need to use a generator. Instead we could use a regular async function.

Let's define a new higher-order helper function, fromActionAsync:

function fromActionAsync<T extends Action>(
  saga: (a1: T, dispatch: (a2: Action) => void) => Promise<void>
): (action: T) => Observable<Action> {
  return a3 =>
    new Observable<Action>(sub => {
      const dispatch = sub.next.bind(sub);

      saga(a3, dispatch)
        .catch(error => sub.error(error))
        .then(() => {
          sub.complete();
        });
    });
}

This one is a lot simpler. Here we just accept a function that accepts both an action and a dispatch() function, and returns a void promise.

The function still returns a new observable, but in this case, instead of iterating over the result of calling saga(), the subscribe function calls it, passing in the action and a dispatch function, then waits for it to error or complete.

We could then use fromActionAsync to define an effect as follows:

public initUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUser),
    switchMap(
      fromActionAsync(async (_, dispatch) => {
        try {
          dispatch(showSpinner());

          const user = await this.usersService.getUser().toPromise();
          const permissions = await this.usersService
            .getPermissions(user.id)
            .toPromise();

          dispatch(loadUserSuccess({ user, permissions }));

          if (permissions.includes('posts:read')) {
            dispatch(
              loadPostsSuccess({
                posts: await this.postsService.getPosts().toPromise()
              })
            );
          }
        } catch (error) {
          dispatch(notifyError(error));
        } finally {
          dispatch(hideSpinner());
        }
      })
    )
  )
);

Our approach is now starting to look more like redux-thunk that redux-saga.

The logic here is so simple that's almost not worth having the fromActionAsync. We could just create and return an observable directly:

public initUser$ = createEffect(() =>
  this.actions$.pipe(
    ofType(loadUser),
    switchMap(
      () =>
        new Observable<Action>(sub => {
          (async () => {
            sub.next(showSpinner());

            const user = await this.usersService.getUser().toPromise();
            const permissions = await this.usersService
              .getPermissions(user.id)
              .toPromise();

            sub.next(loadUserSuccess({ user, permissions }));

            if (permissions.includes('posts:read')) {
              sub.next(
                loadPostsSuccess({
                  posts: await this.postsService.getPosts().toPromise()
                })
              );
            }
          })()
            .catch(error => sub.next(notifyError(error)))
            .finally(() => sub.next(hideSpinner()));
        })
    )
  )
);

At this point, we've boiled things down to pretty idiomatic RxJS. The drawbacks are that we've gradually lost something of the elegant simplicity of the generator approach, where the effect's core logic was almost entirely unencumbered, and that we've lost the ability to end the saga early if the observable is unsubscribed (e.g. due to a new action arriving at the switchMap. For these reasons, I still prefer the generator-based approach.

Conclusion

The above is intended as more of an exploration/experiment than a definite proposal for something you should do in your codebase. At the very least, it's rather non-standard, and any advantage you gain in personal understandability might be offset by unfamiliarity on the part of your coworkers and collaborators.

I'm not sure I would use the above in Angular code I wrote for an employer just yet, but it's definitely something I'll look to experiment more with in personal projects.

Top comments (0)