Defining user on-boarding flows with Redux middlewares
As client applications become increasingly complicated, we as developers need to find better ways to help users understand our products and guide them through the first steps. Usage statistics show that retention and adoption rates depend heavily on how pleasant the the initial contact with a new application really is.
Client frameworks usually don’t focus much on this part of the user experience, so it is up to us to architect maintainable solutions that do not interfere with the general application development. After all, the on-boarding process is — hopefully — just the initial encounter and shouldn’t play a bigger role later on. However, when new features are being introduced to our products, we might want to have additional tutorials and flows that introduce them to the user. We also might want to build these flows, such that a user can restart them, when they once then need to refresh their memory.
We want to architect this feature separate from the rest of the application, and keep the notion of on-boarding out of the components and actions as much as possible. We also want to keep the logic around on-boarding encapsulated at a single place, so changing and testing it becomes as easy and reliable as possible. The best way to accomplish this I found so far, are Redux middlewares. They see every action passing through the system and can react accordingly. In comparison to reducers — where we could also handle on-boarding — middlewares can dispatch their own actions in response to what a user is doing (like navigating the user somewhere else, showing or hiding specialised UI components like overlays, etc.).
I’ve been working with React applications of all sizes of the last couple of years and I’ve been a huge fan for most of that time. Redux works best for me to keep my state management clean and pleasant to work with.
Defining the on-boarding flow
On-boarding usually happens on routes and in components that already exist in the application. Let’s say a user registers by providing his email address and a password or comes in through an OAuth flow from a different service. Now we want them to fill out their profile and then send them to some other routes where we show an overlay explain the features and functionality of each of the components shown there. At some point a user usually takes an action that should advance the on-boarding flow to the next stage.
With this in mind, we define the on-boarding flow as a directed (ideally linear) graph of steps that a user is supposed to follow. Each step has a label assigned to it and a URL where this step is taking place on., Additionally, it also defines a step that follows the current one. We can implement the graph as a simple object:
Additionally, we want to define a set of “milestones” that take a user from one on-boarding step to the next one. Actions are usually a good indicator of a user having completed all necessary conditions to advance the on-boarding flow. The list of milestones can either be another key in the step definitions or simply a flat list of action types. Let’s stick with the simple example for now:
Each of these actions tell our middleware to advance the on-boarding flow to the next step. We just need to have the step the user is currently on and can then redirect the user to the corresponding URL and wait for the user to complete one of the milestone actions. From the steps definition we also know which step to take, afterwards. At some point we want to conclude the on-boarding flow. This usually happens when the user completes the last action on the list. We store the corresponding action type in a separate variable:
Of course, these types don’t need to identify completed XHRs, but can also be button clicks or really anything you want.
Defining flow management actions
We also want a way to set the current on-boarding step, maybe even reset it after the user concludes the on-boarding, or even change the steps and milestones depending on the decisions a user makes along the way. Let’s define a set of actions that accomplish this:
This is pretty straight forward so far. We can use these actions to advance, reset or mutate the on-boarding flow for the user. These actions do not need a reducer, since they are meant to be handled by the middleware and don’t require state changes.
Implementing the middleware
Now let’s think about the middleware itself. The on-boarding flow can have two discrete phases:
- in progress
And it needs to handle three classes of action:
- Actions that always need to be handled
- Actions that need to be handled during on-boarding only
- Actions that need to be handled only when on-boarding is concluded
We also want the middleware to be able to send the user to specific URLs in the application and possibly avoid having the user stray from the path we have laid out for him. A good way to achieve this is using React-Router and React-Router-Redux in order to see locations changes happen as actions.
During on-boarding we want to keep the user in place unless he aborts the on-boarding flow and we want to redirect the user when he completes a milestone action. Let’s add an action for this as well:
We also want a reducer for these actions, since these are not handled by the middleware itself:
We can hook this reducer into our root reducer the regular way. Of course, there are many ways to do this. We can also implement the “push” and “replace” actions as asynchronuous Thunk actions or however else we want. We just need it to redirect the user to where we want to have him.
Some URLs are only meant to be reachable during on-boarding. In this case you send the user to the next route by using “replace” instead of “push” so that using the back-button of the browser will not take him back there. Of course we also need to define this set of routes separately, so that we can navigate the user away from it, when they enter the URL manually:
We now have everything we need in order to write the actual middleware. First, let’s tackle handling the three classes of actions. The first one we want to focus on is the class of actions that we always need to handle. They are concerned with on-boarding state management:
Next, we need to make sure that the middleware only takes action when the on-boarding is not yet concluded. We also want to make sure that user cannot access the URLs we marked as “on-boarding only”, so we add the following code block under our switch statement:
The last thing we need to do is, making sure that the on-boarding flow is being advanced once a user completes a milestone action. So we now add handling of the last class of actions, those we need to act upon when on-boarding is in progress:
The only thing missing now is, that we make sure all actions not being handled by this middleware are being send down the middleware stack and can eventually reach the reducers. This method also has one caveat: it is absolutely possible that the action that advances the on-boarding flow will have to modify the application state, before the redirection to the new URL happens. Because of this, the first thing this middleware does is sending the action along, storing its return value in a variable and only then process the action itself. This way, we can make sure that a possibly required state change has already occurred when we redirect the user to the next URL in the flow. The final version of the middleware looks as follows:
Less than a hundred lines of code for a middleware managing most of your linear on-boarding flow needs is not too shabby. Of course, this is a rather naïve implementation, because it doesn’t handle errors very well. But improving on this implementation to harden it against such bugs shouldn’t be too hard.
There are still a couple of things to keep in mind here. Middlewares are singletons, in such as they keep they’re closure over the entire lifetime of the application. This means that you have to do some additional housekeeping, like resetting the “step” to null every time a user logs out of the application (in case you don’t redirect the user out of the React application).
In some cases you want to handle “SET_ONBOARDING_STEP” in a reducer as well, so that the rest of the application knows about an on-boarding that’s going on and can show/hide specific parts of the UI. Of course, you can also keep it more generic and have the middleware dispatch additional actions to show/hide those UI parts.
I realise that this can be implemented almost identically as a reducer. Some people might even argue that it should. I found some issues with that, though, that are easier resolved using a middleware:
- Reducer can’t dispatch actions.
- Implementing the respective actions directly in the reducer hides them from the rest of the application.
- Redirecting within a reducer bears the risk of ending up on a route where the component being shown needs state that has not yet changed in response.
- I like to keep side-effects localised to the middlewares. They have to go somewhere, but I prefer them all in one place.
That being said, there is nothing wrong with going with a reducer instead, if that works better for you.
I would like to thank Simplesurance GmbH for giving me the opportunity to write about some of the work I did for them as a freelance software developer in October and November of 2016. I developed this middleware while working on the Garden web application and it is actively being used there to help on-boarding their users and guiding them through the registration process.
12/02/2016: Added a “Disclaimer” section, fixed typos and missing line-breaks, added more comments to middleware.js