Structuring Conditionals in a Wizard

Joël Quenneville

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, and create_user)
  • Address (confirm_email, reset_timer, geo_locate, and create_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.

control flow diagram showing a wizard implemented as a single linear flow

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:

control flow diagram showing a wizard implemented by branching 3 ways early

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.