Two kinds of multi-step forms
The creation of multi-step forms is a relatively common challenge faced in Rails programming (and probably in web development in general of course). Unlike regular CRUD interfaces, there’s not a prescribed “Rails Way” to do multi-step forms. This, plus the fact that multi-step forms are often inherently complicated, can make them a challenge.
Following is an explanation of I do multi-step forms. First, it’s helpful to understand that there are two types of them. There’s an easy kind and a hard kind.
After I explain what the easy kind and the hard kind of multi-step forms are, I’ll show an illustration of the hard kind.
The easy kind
The easy kind of multi-step form is when each step involves just one Active Record model.
In this scenario you can have a one-to-one mapping between a controller and a step in the form.
For example, let’s say that you have a form with two steps. In the first step you collect information about a user’s vehicle. The corresponding model to this first step is Vehicle
. In the second step you collect information about the user’s home. The corresponding model for the second step is Home
.
Your multi-step form code could involve two controllers, one called Intake::VehiclesController
and the other called Intake::HomesController
. (I’m making up the Instake
namespace prefix because maybe we’ll decide to call this multi-step form the “intake” form.)
To be clear, the Intake::VehiclesController
would exist in addition to the main scaffold-generated VehiclesController
, not instead of it. Same of course with the Intake::HomesController
. The reason for this is that it’s entirely likely that the “intake” controllers would have different behavior from the main controllers for those resources. For example, Intake::VehiclesController
would probably only have the new
and create
actions, and none of the other ones like delete
. Intake::VehiclesController#create
action would also probably have a redirect_to
that sends the user to the second step, Intake::HomesController#new
, once the vehicle information is successfully collected.
To summarize the solution to the easy type of multi step form: For each step of your form, create a controller that corresponds to the Active Record model that’s associated with that step.
Again, this solution only works if your form steps and your Active Record models have a one-to-one relationship. If that’s not the case, you’re probably not dealing with the easy type of multi-step form. You’re probably dealing with the hard kind.
The hard kind
The more difficult kind of multi-step form is when there’s not a tidy one-to-one mapping between models and steps.
Let’s say, for example, that the multi-step form needs to collect user profile/account information. The first step collects first name and last name. The second step collects email and password. All four of these attributes (first name, last name, email and password) exist on the same model, User
. How do we validate the first name and last name attributes in the first step when the User
model wants to validate a whole User
object at a time?
The answer is that we create two new concepts/objects called, perhaps, Intake::UserProfile
and Intake::UserAccount
. The Intake::UserProfile
object knows how to validate first_name
and last_name
. The Intake::UserAccount
knows how to validate email
and password
. Only after each form step is validated do we attempt to save a User
record to the database.
If you found the last paragraph difficult to follow, it’s probably because I was describing a scenario that isn’t very common in Rails applications. I’m talking about creating models that don’t inherit from ActiveRecord::Base
but rather that mix in ActiveModel::Model
in order to gain some Active-Record-like capabilities.
All this is easier to illustrate with an example than to describe with words, so let’s get into the details of how this might be accomplished.
The tutorial
Overview
This tutorial will illustrate a multi-step form where the first step collects “user profile” information (first name and last name) and the second step collects “user account” information (email and password).
Although I’m aware of the Wicked gem, my approach doesn’t use any external libraries. I think gems lend themselves well to certain types of problems/tasks, like tasks where a uniform solution works pretty well for everyone. In my experience multi-step forms are different enough from case to case that a gem to “take out the repetitive work” doesn’t really make sense because most of the time-consuming work is unique to that app, and the rest of the work is easily handled by the benefits that Rails itself already provides.
Here’s the code for my multi-step form, starting with the user profile model.
The user profile model
What will ultimately be created in the database as a result of the user completing the multi-step form is a single User
record.
For each of the two forms (again, user profile and user account) we want to be able to validate the form but we don’t necessarily want to persist the data yet. We only want to persist the data after the successful completion of the last step.
One way to achieve this is to create forms that don’t connect to an ActiveRecord::Base
object, but instead connect to an ActiveModel::Model
object.
If you mix in ActiveModel::Model
into a plain old Ruby object, you gain the ability to plug that object into a Rails form just as you would with a regular ActiveRecord
object. You also gain the ability to do validations just as you would with a regular ActiveRecord
object.
Below I’ve created a class that will be used in the user profile step. I’ve called it UserProfile
and put it under an arbitrarily-named Intake
namespace.
module Intake
class UserProfile
include ActiveModel::Model
attr_accessor :first_name, :last_name
validates :first_name, presence: true
validates :last_name, presence: true
end
end
The user profile controller
This model will connect to a controller I’m calling UserProfilesController
. Similar to how I put the UserProfile
model class inside a namespace, I’ve put my controller class inside a namespace just so my root-level namespace doesn’t get cluttered up with the complex details of my multi-step form.
I’ve annotated the controller code with comments to explain what’s happening.
module Intake
class UserProfilesController < ApplicationController
def new
# An instance of UserProfile is created just the
# same as you would for any Active Record object.
@user_profile = UserProfile.new
end
def create
# Again, an instance of UserProfile is created
# just the same as you would for any Active
# Record object.
@user_profile = UserProfile.new(user_profile_params)
# The valid? method is also called just the same
# as for any Active Record object.
if @user_profile.valid?
# Instead of persisting the values to the
# database, we're temporarily storing the
# values in the session.
session[:user_profile] = {
'first_name' => @user_profile.first_name,
'last_name' => @user_profile.last_name
}
redirect_to new_intake_user_account_path
else
render :new
end
end
private
# The strong params work exactly as they would
# for an Active Record object.
def user_profile_params
params.require(:intake_user_profile).permit(
:first_name,
:last_name
)
end
end
end
The user profile view
Similar to how the user profile controller is very nearly the same as a “regular” controller even though no Active Record class or underlying database table is involved, the user profile form markup is indistinguishable from the code that would be used for an Active-Record-backed class.
<h2>User Profile Information</h2>
<%= form_with(model: @user_profile, url: local: true) do |f| %>
<% if @user_profile.errors.any? %>
<div id="error_explanation">
<b><%= pluralize(@user_profile.errors.count, "error") %> prohibited this user profile from being saved:</b>
<ul>
<% @user_profile.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :first_name %>
<%= f.text_field :first_name, class: 'form-control' %>
</div>
<div class="form-group">
<%= f.label :last_name %>
<%= f.text_field :last_name, class: 'form-control' %>
</div>
<%= f.submit 'Next Step', class: 'btn btn-primary' %>
<% end %>
The user account model
The user account model follows the exact same principles as the user profile model. Instead of inheriting from ActiveRecord::Base
, it mixes in ActiveModel::Model
.
module Intake
class UserAccount
include ActiveModel::Model
attr_accessor :email, :password
validates :email, presence: true
validates :password, presence: true
end
end
The user account controller
The user account controller differs slightly from the user profile controller because the user account controller step is the last step of the multi-step form process. I’ve added annotations to this controller’s code to explain the differences.
module Intake
class UserAccountsController < ApplicationController
def new
@user_account = UserAccount.new
end
def create
@user_account = UserAccount.new(user_account_params)
if @user_account.valid?
# The values from the previous form step need to be
# retrieved from the session store.
full_params = user_account_params.merge(
first_name: session['user_profile']['first_name'],
last_name: session['user_profile']['last_name']
)
# Here we finally carry out the ultimate objective:
# creating a User record in the database.
User.create!(full_params)
# Upon successful completion of the form we need to
# clean up the session.
session.delete('user_profile')
redirect_to users_path
else
render :new
end
end
private
def user_account_params
params.require(:intake_user_account).permit(
:email,
:password
)
end
end
end
The user account view
Like the user profile view, the user account view is indistinguishable from a regular Active Record view.
<h2>User Account Information</h2>
<%= form_with(model: @user_account, local: true) do |f| %>
<% if @user_account.errors.any? %>
<div id="error_explanation">
<b><%= pluralize(@user_account.errors.count, "error") %> prohibited this user account from being saved:</b>
<ul>
<% @user_account.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
</div>
<div class="form-group">
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
</div>
<%= f.submit 'Save and Finish', class: 'btn btn-primary' %>
<% end %>
Routing
Lastly, we need to tie everything together with some routing directives.
Rails.application.routes.draw do
namespace :intake do
resources :user_profiles, only: %i[new create]
resources :user_accounts, only: %i[new create]
end
end
Takeaways
There are two types of multi-step forms. The easy kind is where each step corresponds to a single Active Record model. In those cases you can use a dedicated controller per step and redirect among the steps. The harder kind is where there’s not a one-to-one mapping. In those cases you can mix in ActiveModel::Model
to lend Active-Record-like behaviors to plain old Ruby objects.
In addition to the bare functionality I described above, you can imagine multi-step forms involving more sophisticated requirements, like the ability to go back to previous steps, for example. I didn’t want to clutter my tutorial with those details but I think those behaviors would be manageable enough to add on top of the underlying overall approach I describe.
As a last side note, you don’t have to use session storage to save the data from each form step until the final aggregation at the end. You could store that data any way you want, including using a full-blown Active Record class for each step. There are pros and cons to the various possible ways of doing it. One thing I like about this approach is that I don’t have any “residual” data lying around at the end that I have to clean up. I can imagine other scenarios where other approaches would be more appropriate though, e.g. multi-step forms that the user would want to be able to save and finishing filling out at a later time.
The most important thing in my mind is to keep the multi-step form code easily understandable and to make its code sufficiently modularized (e.g. using namespaces) that it doesn’t clutter up the other concerns in the application.