In our quest to provide value and service to our users, sometimes we need them to provide a significant amount of input before we can help. Whether they are signing up, showing interest in a product, or making a purchase, there is some necessary information we must gather.
In the dark ages of the web, this would have been done through a monolithic “megaform”—a looming beast of a form which hits the user with dozens of text fields, radio buttons, and check boxes all at once. Luckily, we live in more enlightened times, and we have a tool that is much better at eliciting input from users: the multi-step form.
Rather than being faced with one giant form, multi-step forms (also called “wizards”) allow users to provide information in self-contained “chunks”. The UX benefits of this are undeniable. It makes our forms much less intimidating, which reduces friction and leads to much better conversion rates.
Usually, building a multi-step form means loading all of the steps into the browser at once, and then controlling the flow of the form in the client—only showing the current step, while hiding the rest. Whatever front-end framework you are using (React, Vue, etc.), there is probably a library to help you achieve this. But what about if you are not using a front-end framework, or you just prefer to do things on the server?
In this series of articles, we’ll explore how you might build a server-side multi-step form in a Rails application. I’ll be breaking the process down into building the four main components we will need for our wizard:
- An object that maintains state between databases
- A group of objects that validate and process user input
- An object that handles control flow
- A controller that puts it all together
Before we begin, I’d like to address why building a wizard on the server might be preferable to building it in the client. As I said before, if you aren’t using a front-end framework, and you are not looking to adopt one, building it on the server will probably be less work in the long run than trying to build your own client-side solution. The main benefit is that, in a Rails app, you are able to leverage existing resources to integrate features into your form, including:
- Input validation – It’s important that users know when they’ve entered invalid information. Sometimes we even need to communicate with the server to know if information is invalid (such as someone signing up with a duplicate email address). This is a problem Rails solved long ago with
ActiveModel::Validations
- Data tracking – As we’ll see later, we will have to update the database whenever users complete a step. In addition to maintaining state, this also allows us to track the performance of our form. Being able to see what information is and isn’t present on incomplete submissions can show us where we might be losing users
- Business logic – Handling step submissions on the server allows us easily apply more complex business logic to user input
- Dynamic steps – Handling control-flow on the server allows us to make our multi-step form more dynamic based on the current state of the form
Outlining The Project
To demonstrate how you might build a multi-step form, let’s define a project where we would use one. I am a fan of quality watches (as I write this, I’m sporting one of my favorite pieces from Shinola), but I wish there was a better way to expand my collection without going broke. Let’s say I start a subscription business where users can sign up to receive a different timepiece every month or so, and either send it back for another, or pay to keep it. In order to do this, we’ll need the user to provide the following information:
- Their delivery address
- Their wrist size
- What size watch they prefer (if any)
- What type of strap they would prefer (if any)
- What type of watch movement they prefer (if any)
- If they prefer watches for formal or casual wear
- How often they would like to receive a new watch
- How much they would be willing to pay to keep a watch
- What type of account they would like (standard account, or a premium account for access to higher-end watches)
- Their payment information
As you can see there is quite bit of information the user will have to provide. But, looking at the list of questions, we can also start to see that some questions can be grouped into a similar categories, which is how we will determine what will be asked in each step.
Before we do that, though, we have to figure out how we are going to maintain the state of our multi-step form.
Part 1: Maintaining State With an Entity Candidate
In a client-side form, this doesn’t present much of a problem, since all the input is preserved until the form is submitted. But with a server-side solution, our multi-step forms are going to be broken up into different forms for each step, which will be submitted to the server independent of one another. So how do we make sure we are preserving the input submitted, and sharing it with the other steps?
Before we answer that question, we need to do a little data modeling for our app. First, let’s create a model where all of this information will ultimately be saved. Let’s call this model our Subscription
model. And our migration file should probably look something like this:
class CreateSubscriptions < ActiveRecord::Migration[5.2] def change create_table :subscriptions do |t| t.references :user t.references :address t.references :payment_source t.float :wrist_size, null: false t.string :preferred_watch_size t.string :preferred_movement_type t.string :preferred_style t.integer :delivery_period_length, null: false t.integer :value_limit t.string :account_type, null: false end end end
And our model would look like this:
class Subscription < ApplicationRecord WRIST_SIZES = [ WRIST_SIZE_SMALL = "small", WRIST_SIZE_MEDIUM = "medium", WRIST_SIZE_LARGE = "large" ] WATCH_SIZES = [ WATCH_SIZE_SMALL = "small", WATCH_SIZE_MEDIUM = "medium", WATCH_SIZE_LARGE = "large" ] MOVEMENT_TYPES = [ QUARTZ = "quartz", MECHANICAL = "mechanical", AUTOMATIC = "automatic" ] STYLES = [ FORMAL = "fomal", CASUAL = "casual" ] DELIVERY_PERIOD_LENGTHS = [1, 2, 3, 4] ACCOUNT_TYPES = [ BASIC = "basic", PREMIUM = "premium", VIP = "vip" ] belongs_to :user belongs_to :address belongs_to :payment_source validates :preferred_watch_size, inclusion: { in: WATCH_SIZES }, allow_blank: true validates :preferred_movement_type: inclusion: { in: MOVEMENT_TYPES }, allow_blank: true validates :preferred_style, inclusion: { in: STYLES } validates :wrist_size, presence: true, inclusion: { in: WRIST_SIZES } validates :delivery_period_length, presence: true validates :account_type, presence: true, inclusion: { in: ACCOUNT_TYPES } end
Looking at our model, it’s easy to see that we won’t be able to update it in pieces. There are several attributes that must be present when the model is validated to ensure we have all of the information we need. So if we are going to submit data to the server on each step of the form, we won’t be able to save it to this model to maintain state.
This is where entity candidates comes in. With the entity candidate design pattern, instead of saving piecemeal data to our final entity, we build up an entity candidate step by step. Once the candidate has all the data it needs, we can apply whatever business logic we need to make our candidate into our final entity record. This way, we can maintain the state of the form within the candidate without violating any of the entity’s validations (we will see later how we can apply only the relevant validations to each step as they are submitted).
In our case, since we are trying to create a final Subscription
record, we would want to create a counterpart candidate model, which we will call SubscriptionCandidate
. Our migration for this model would look like this:
class CreateSubscriptionCandidates < ActiveRecord::Migration[5.2] def change create_table :subscriptions do |t| t.references :user t.references :address t.references :payment_source t.references :subscription t.string :uuid t.float :wrist_size t.string :preferred_watch_size t.string :preferred_movement_type t.string :preferred_style t.integer :delivery_period_length t.integer :value_limit t.string :account_type end end end
And the model would look like this:
class Subscription < ApplicationRecord belongs_to :user belongs_to :address belongs_to :payment_source belongs_to :subscription before_create :generate_uuid private def generate_uuid self.uuid = SecureRandom.uuid unless self.uuid.present? end end
Pretty simple, right? Now we have a model that reflects Submscription
, but isn’t restricted by its validations, allowing us to update the record in the database whenever a new step of the form is completed.
There are two noteworthy additions to our candidate that are not present in the subscription. The first is a belongs_to
relationship with Subscription
. When we create a subscription from a candidate, we will want to update the candidate with the created subscription’s id. That way we know that candidate was successfully completed. The second addition is a uuid. We will explore why this is important when we build the controller.
The only thing missing is the business logic that will turn our candidate into a full-blown Subscription
. Let’s add that now:
class SubscriptionCandidate < ApplicationRecord belongs_to :user belongs_to :address belongs_to :payment_source belongs_to :subscription before_create :generate_uuid def complete if subscription = Subscription.create(completion_attributes) update(subscription: subscription) else false end end private def generate_uuid self.uuid = SecureRandom.uuid unless self.uuid.present? end def completion_attributes attributes.symbolize_keys.slice( :user_id, :address_id, :payment_source_id, :wrist_size, :preferred_watch_size, :preferred_movement_type, :preferred_style, :delivery_period_length, :value_limit, :account_type ) end end
Our candidate now has a public method for creating a new subscription record using the data it has stored from each step.
Now we have a solution for maintaining the state of the user’s input as they go through our multi-step form. In the next article, we’ll see how we can break down expected input into steps, and how we can uniformly process that input .