Using a message bus and React context instead of Redux with promise based APIs

Why do you want to do this?

Setting up a React context is easy — there are plenty of resources showing you how but the basics are to hook in somewhere in your call stack:

function Main (props) {
  return (
    <div>
      <AccountProvider>
          ...
                        <VersionsProvider>
                            <NotificationsProvider>
                              ...
                                            <AppWithAuth/>

and then, in our case, define a reducer for use in the context:

const VersionsContext = React.createContext(EMPTY_STATE);

function VersionsProvider(props) {
  const { children } = props;
  const [state, dispatch] = useReducer(reducer, EMPTY_STATE);
  const [isInitialization, setIsInitialization] = useState(true);

  useEffect(() => {
    if (isInitialization) {
      beginListening(dispatch);
      const lfg = new LocalForageHelper(VERSIONS_CONTEXT_NAMESPACE);
      lfg.getState()
        .then((diskState) => {
          const myDiskState = diskState || MY_STORED_EMPTY_STATE;
          dispatch(initializeVersionsAction(myDiskState));
          setIsInitialization(false);
        });
    }
    return () => {
    };
  }, [isInitialization]);

  return (
    <VersionsContext.Provider value={[state, dispatch]}>
      {children}
    </VersionsContext.Provider>
  );
}

export { VersionsContext, VersionsProvider };

Now you can use your contexts anywhere you like:

const [investiblesState, investiblesDispatch] = useContext(InvestiblesContext);
const [, versionsDispatch] = useContext(VersionsContext);

so long as you are inside a function where React hooks are viable. Setting up a message bus is similarly simple — in this case Uclusion uses the Amplify library:

function beginListening (dispatch) {
  registerListener(VERSIONS_HUB_CHANNEL, 'versionVersionStart', (data) => {
    const { payload: { event, globalVersion, notificationVersion, marketId } } = data;
    switch (event) {
...
      case NOTIFICATION_MESSAGE_EVENT:
        return getNotifications()
          .then((notifications) => {
            const notification = notifications.find((item) => item.type_object_id.startsWith('notification'));
          dispatch(refreshNotificationVersionAction(notification));
          });
      case NOTIFICATION_VERSION_UPDATE:
        dispatch(updateNotificationVersion(notificationVersion));
        break;
      default:
    }
  });
}

where

import { Hub } from '@aws-amplify/core';
const busListeners = {};

/* Adds a listener to under the UNIQUE name, to the channel
 * If a listener with the name already exists, it will be removed
 * before this one is added
 */
export function registerListener(channel, name, callback) {
  const previousListener = busListeners[name];
  if (!!previousListener) {
    Hub.remove(channel, previousListener);
  }
  busListeners[name] = callback;
  Hub.listen(channel, busListeners[name]);
}

Setting up and using redux-thunk or redux-promise so that Redux can work with Promises is IMHO much harder and not so readily in many developer’s wheelhouse.

What can I do once I have that setup?

Once you have the above setup the pattern is very simple; when a Promise resolves send a message to one of your listeners and they will in turn dispatch to their reducer.

For instance let’s say I have a simple reducer:

function computeNewState (state, action) {
  switch (action.type) {
    case UPDATE_MESSAGES:
      return doUpdateMessages(state, action);
    case PAGE_CHANGED:
      return processPageChange(state, action);
    case INITIALIZE_STATE:
      return action.newState;
    default:
      return state;
  }
}

function reducer(state, action) {
  const newState = computeNewState(state, action);
  if (action.type !== INITIALIZE_STATE) {
    const lfh = new LocalForageHelper(NOTIFICATIONS_CONTEXT_NAMESPACE);
    lfh.setState(newState);
  }
  return newState;
}

which is listening for messages. I can send to that reducer from within an API call (getMessages() returns a Promise) in another reducer

function processNewNotification (newNotificationVersion, notificationVersion) {
  const { version: notificationVersionNumber } = notificationVersion || {};
  const { version: newNotificationVersionNumber, hkey, rkey, is_remove: isRemove } = newNotificationVersion || {};
  if (notificationVersionNumber !== newNotificationVersionNumber) {
    getMessages().then((messages) => {
      const latest = messages.find((message) => (message.type_object_id === rkey
        && message.market_id_user_id === hkey));
      if (isRemove === _.isEmpty(latest)) {
        pushMessage(NOTIFICATIONS_HUB_CHANNEL, { event: VERSIONS_EVENT, messages });
        pushMessage(VERSIONS_HUB_CHANNEL, {event: NOTIFICATION_VERSION_UPDATE,
          notificationVersion: newNotificationVersionNumber})
      }
    });
  }
}

that also sends a message to its own listener! The basic flow

Promise resolves => message bus => listener(s) => call dispatch

is very powerful. You can see production source code following this pattern here.

Is there an even easier way to do this?

At the time of this writing the debate is still raging in this bug. The dream is of course to use Promises within your code as if they were synchronous but that’s a tall order. In the mean time a React context / reducer and a message bus can make the data you are fetching available to your entire application without that much effort.

David Israel
David Israel Co-Founder of Uclusion