Multi-step forms (“wizards”) often grow organically, adding a conditional here, a new step there. This can quickly evolve into a scary tangle of conditional code that no one dares to modify.
There is a better way! I’ve worked on several projects, both Rails and Elm, where the wizard code had gotten so convoluted that the team were scared to work with it. Applying the refactors shown below had a major impact and allowed everyone to feel confident when dealing with this part of the system.
Single flow of control
As developers we love to put everything into a single linear flow. Sure, there may be diversions off the main path but as quickly as possible we try to rejoin.
Consider a wizard consists of 3 steps, each of which might take several actions like saving to the database or sending an email. Both the bio and address steps have must be completed within a limited time window, so that’s a shared action between them.
- Bio (
fetch_alma_mater
,reset_timer
, andcreate_user
) - Address (
confirm_email
,reset_timer
,geo_locate
, andcreate_address
) - Socials (
verify_number
)
In a Rails app, a common implementation might be wrapping each of these actions in an independent condition that checks for the presence of a param or a boolean flag. While this may look clean and composable, it scales very poorly.
if params[:user][:school].present?
fetch_alma_mater
end
if params[:address][:email].present?
confirm_email
end
if params[:address][:phone].present? && @user.active?
verify_number
end
# 4 more of these...
We might represent these visually using a control flow diagram.
This style is hard to read. It is challenging to figure out what code will get executed for a given step. Are you chasing down a bug in the bio step? Good luck finding which of these lines is relevant to your search.
This style is also hard to change. If you want to add extra behavior to the
step that gathers biographical info (and only that step), is it safe to do so
inside the if params[:user][:name].present?
branch? It’s hard to say. Maybe
you should wrap the new behavior in its own conditional? Pretty soon this file
will be a mess of defensive code.
Combinatorial explosion
Part of the problem is that all these independent conditions create a combinatorial explosion of paths through the code. Even though our problem only describes 3 separate steps for our wizard, the way our code is structured allows for 19 different paths! That’s 4 branches that may or may not be triggered (2^4 = 16 combinations) plus 3 possible endings from the full diagram.
Most of these “should never happen”. For example, know that the socials step
should only execute the verify_number
action but you’d never know that by
looking at the diagram or the code! The reality is that it’s completely possible
for the sequence confirm_email + fetch_alma_mater + reset_timer +
verify_number
to execute.
No wonder the code is a nightmare to read and modify!
Branching early
Let’s give up on the idea of a single linear flow. We know this is a problem
that inherently has 3 distinct paths so let’s not fight that. Instead, we’ll do
an early multi-way branch using a case
expression.
case step
when :bio
fetch_alma_mater
reset_timer
create_user
when :address
confirm_email
reset_timer
geo_locate
create_address
when :socials
verify_number
end
Visually, we might represent this using the following control flow diagram:
It’s now much easier to answer questions like “what actions get triggered in the bio step?” and “which steps use the shared action?”? Because you only need to hold 3 paths in your head (instead of 19), it’s much easier to read the code and get a high-level understanding of what it does.
Branching even earlier with routes
When the body of a Rails controller action is a big case
expression, it’s
often a sign that this could better be modeled as multiple resources. Let the
router handle the branching instead of doing it manually.
Wizards are no exception. In general, I find the best structure is to have each step be its own controller. Use your favorite code-sharing mechanism for work that is in common to several steps.
# config/routes.rb
resource :bio, only: [:edit, :update]
resource :address, only: [:edit, :update]
resource :socials, only: [:edit, :update]
Modeling a wizard on the front end
Wizards are also a common experience to implement in a front-end app. Consider an implementation in Elm. Of course, we start with the data structure:
type alias User =
{ name : Maybe String
, email : Maybe String
, phone : Maybe String
, active : Maybe Bool
}
This data structure has 24 possible states, many of which are invalid! For example, is it possible to have a phone number (captured in step 3) but no name (captured in step 1)?
Instead, we could refactor to a custom type that clearly represents each of our steps. This type makes it clear that at each step, we are guaranteed to have the data from the previous steps.
type Wizard
= Bio (Maybe String)
| Address { name : String} (Maybe String)
| Socials { name : String, address : String } (Maybe String)
| Complete { name : String, address : String, phone : String }
You may wonder why we’re looking at data structures here rather than conditional code like in the Ruby examples above. This is because your choice of data structure impacts how you will branch on it!
The record pushes you towards nested conditions checking for the presence of various fields, kind of like the original Ruby example. The custom type on the other hand encourages you to write one flat case expression.