Skip to content
Colin Wren
Twitter

Using redux-saga to handle (and test) Push Notifications in React Native

JavaScript, Software Development6 min read

a person kicking another person off a cliff
Photo by Ashley Jurius on Unsplash

Half way through December 2019 I was asked to build a new React Native app for the company I maintain an existing React Native app for and I was really excited to do so, seeing it as a means to do some greenfield development and do things properly from the start.

As part of the maintenance work I had been building a React component library for the existing native app, in order to abstract the components from the app logic and make them re-usable (should we end up building a new app, good foresight on my part there!).

While building that component library I had used Expo as a means to speed up development and found it an amazing tool for React Native, so I decided I would build this new project with Expo as I was given a deadline of 2 months to build the app.

The new app’s stack consisted of:

  • React — Because React is a great component library
  • React Native — Because how else would I build a React Native app?
  • Expo — Offers amazing deployment functionality and has a great set of libraries for speeding up development
  • My component library — To re-use as many components as possible from the company’s existing React Native app
  • Redux — While people argue that React Hooks does away with the need for it I think it’s still a great state management solution and it’s more widespread that React Hooks are meaning other developers can pick up the codebase easily
  • Redux Saga — Because the existing app had a lot of side-effects and I was going to be re-using some of that app’s logic
  • React Navigation — The existing app used it and I was going to be re-using some of the navigation code

One of Expo’s benefits, aside from its awesome deployment tools (more on that in a future blog post) is that it also makes push notifications much easier to deal with.

You still need to jump through the usual hoops with Apple to create a .p8 certification but once this is uploaded to Expo’s servers you can use a simple API call to send the notifications, provided you know the Expo push token of the device to send it to.

This API comes in very handy when testing or debugging your app as you can trigger the notifications easily via a POST request.

I found this extremely useful when I introduced notification categories to the app as the category ID changes between apps in Expo and the standalone versions.

redux-saga Basics

redux-saga is ‘an alternative side effect model for Redux apps’ but it’s the only side effect model for Redux I’ve ever known! Although I believe the term generally used for this type of abstraction is an ‘Epic’.

redux-saga offers a set of functions for dealing with the Redux store and process forking, the most common are:

  • put — Dispatch an action to the Redux store
  • takeEvery — Perform a specified function (task) every time the action is added to the Redux store
  • takeLatest — Perform a specified function every time the action is added to the Redux store but if there’s a currently running task for that action kill that before running the new one
  • take — Wait for the specified action to be added to the Redux store and store that action (if we need to)
  • call — Call a function (the function and it’s arguments are params so function(arg1, arg2) would be call(function, arg1, arg2) )
  • fork — Fork the process for the specified task (useful for long running tasks), you can use cancel to cancel that task

redux-saga uses generator functions to handle the operations it needs to perform, which to summarise (poorly) means that it returns an iterator with each operation being yield ed so the next operation can be performed on a call of next() .

The use of the generator functions makes it incredibly easy to test a redux-saga function as you can call next() and ensure that the outcome is what you expect to happen.

These generator functions aren’t called directly however but instead called by watcher functions which take actions being dispatched to the Redux store and then iterate over the generator function’s operations with one of those operations dispatching an action to the Redux store to update the app state on the outcome.

A common pattern would be:

  1. The app dispatches a ‘request’ action (such as a submit form) which gets picked up by the watcher which then calls the generator function (saga)
  2. The saga will then do a bunch of operations (such as an API call to send the form data to a server) and if successful will put a ‘response’ action or an ‘error’ action is unsuccessful
  3. The app’s reducer then takes the action dispatched by the saga and following the standard Redux flow updates the app’s state

This approach works well as you can unit test every aspect of it:

  • The actions can be tested to ensure they return the correct object
  • The reducer can be tested to ensure on processing the response action the app’s state is correctly updated
  • The saga can be tested to ensure that on a successful API call it dispatches the response action and on the API call throwing an exception it dispatches the error action
  • You can use something like Enzyme to ensure when the button is pressed that it dispatches the request action

So you know how redux-saga works, now let’s cover how push notifications work in Expo apps.

Push Notifications using Expo

In order to handle push notifications in your app using Expo you need to create an event listener using Expo’s Notifications.addListener function.

This event listener’s callback will then receive the following object on a notification being sent:

  • remote — Was the notification a remote or local one?
  • data — The data object sent with the notification
  • actionId — The action the user pressed (if categories are used)
  • origin — Used to determine if the notification was received when the app was open or not
  • userText — If the notification allows user input this stores that input

The event listener works well for React components as you can use the useEffect hook to set up the listener when the component is first mounted and then use the functions created by useState to update the application state on receiving a notification.

Things get a little tricky however when you want to abstract the notification handling logic out into a separate process running in the background.

Handling Push Notifications using redux-saga

I made the decision to abstract out the push notification logic in the app into a saga as I made the design decision with the code base that all side-effects (which I consider push notifications to be) were to be handled via redux-saga meaning that there’s only one place any non-display related logic lives in the codebase.

The design decision came out of looking at the existing app code and how it had functions that called other functions without callbacks/promises/async await and how much of a ball-ache that was to follow.

The first step to handling push notifications with redux-saga is knowing how to deal with event listeners with redux-saga — Event Channels.

Event Channels are used essentially like a wrapper around the event listener function and expose an emit function that can be used within the event listener callback to ‘dispatch’ to the channel.

After the channel is set up a watcher on the channel can then take the events ‘dispatched’ to the channel and process them within the standard redux-saga manner using put or call .

This watcher does need to be in a while loop so it can take every event and due to this the watcher process needs to be run as a separate task via fork to prevent it from blocking the function that sets up the event channel.

Testing this approach

As stated previously the benefit of using redux-saga is that it’s incredibly easy to test everything and event channels are no different.

Before testing the event channel you’ll first need to find a means of calling the callback passed to the event listener that the event channel wraps, as I use Jest for my testing I used jest.mock to mock out the expo module with an object that provides Notifications.addListener and then using mockImplentation save the callback into an object I can call later.

Once you have the callback stored you can then create an instance of the event channel that will be used in a Promise that will resolve once the callback has been called and the event channel has an event ‘dispatched’ to it.

Once this Promised is resolved you can then assert on the event that was taken from the event channel.

Here’s an example of such a test:

1const mockListenerCallback = null;
2
3jest.mock('expo', () => ({
4 Notifications: {
5 addListener: jest.fn().mockImplementation((callback) => {
6 mockListenerCallback = callback;
7 }),
8 },
9}));
10
11function getPushNotificationChannel() {
12 return eventChannel((emit) => {
13 const listener = Notifications.addListener((data) => emit(data));
14 return () => listener.remove();
15 });
16}
17
18
19function* watchPushNotification(channel) {
20 while (true) {
21 const data = yield take(channel);
22 yield put(somePushNotificationAction(data));
23 }
24}
25
26function* setUpPushSaga() {
27 let token = null;
28 try {
29 token = yield call(Notifications.getExpoPushTokenAsync);
30 if (token !== null) {
31 yield put(someActionToStoreToken(token));
32 }
33 } catch (err) {
34 yield put(someErrorAction('expo badness'));
35 }
36
37 // If token has been set then set up listener
38 if (token !== null) {
39 try {
40 // Set up an event channel to process incoming notifications
41 const notificationChannel = yield call(getPushNotificationChannel);
42 // Fork the process so we can continue with the rest of saga
43 yield fork(watchPushNotification, notificationChannel);
44 } catch (err) {
45 yield put(someErrorAction('listener badness'));
46 }
47 } else {
48 yield put(someErrorAction('all round badness'));
49 }
50}
51
52
53describe('watchPushNotification', () => {
54 it('Takes the push notification payload and dispatches it to an action', () => {
55 const channel = 'channel';
56 const generator = watchPushNotification(channel);
57 expect(generator.next(data).value).toEqual(take(channel));
58 expect(generator.next(data).value).toEqual(put(somePushNotificationAction(data)));
59 });
60});
61
62describe('setUpPushSaga', () => {
63 it('Sends token to server on getting token', () => {
64 const generator = setupPushNotificationsSaga();
65 expect(generator.next().value).toEqual(call(Notifications.getExpoPushTokenAsync));
66 expect(generator.next(token).value).toEqual(put(someActionToStoreToken(token)));
67 expect(generator.next().value).toEqual(call(getPushNotificationChannel));
68 expect(generator.next(channel).value).toEqual(fork(watchPushNotification, channel));
69 expect(generator.next().done).toBe(true);
70 });
71});
72
73describe('getPushNotificationChannel', () => {
74 it('Creates a redux-saga event channel to handle the push notification listener', async () => {
75 const data = { a: 'b' };
76 const channel = getPushNotificationChannel();
77 const taken = new Promise((resolve) => channel.take(resolve));
78 mockListenerCallback(data);
79 const value = await taken;
80 expect(value).toEqual(data);
81 });
82});
Example of the different functions and tests for them (please note this is pseudo code)

You can then test the watcher saga in the same way that you would test other redux-saga generators but without the last assertion that the generator is done as the while loop prevents that.

And you can test the saga that creates the event channel, watcher and fork s them in the same way you test your other redux-saga generators.

Summary

By using an event channel to manage the push notification listener used by Expo you can handle the push notification objects as you would any other action within Redux.

By using redux-saga to handle the listening for and processing of push notification you can create an easy to maintain and easy to test codebase which is key in the ever evolving world of React and React Native.