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.
Nice work Jason… just what I needed!
Thanks, glad it helped!
Great article, I didn’t know about the ActiveModel before your post. Have you considered alternate approaches to persisting partial attributes? For instance, what if you wanted to support “continue where you left off” or capture which users partially completed registration?
Thanks! I can imagine a number of ways of implementing such a thing, but I’ve never built a feature quite like that. One idea that comes to mind is saving all the data like normal but then having some sort of “finalized” flag that only gets set when everything is complete. Or I could store the data in some unstructured way, like a JSON value, until the time I wanted to validate and finalize everything. Or maybe something else. It would really depend on the scenario.
Hey, Jason! Which project directory do you leave the user profile and user account files? I’m getting errors for loading then. And the module/namespace is really necessary? Thanks.
I would put them in app/models/intake/user_profile.rb and app/models/intake/user_account.rb. The module/namespace isn’t necessary but I think it’s a nice way to keep the code organized and keep related files clearly together.
Hi Jason,
This was very helpful. And where do you put the controller and view files?
Thanks so much!
I make a subdirectory in the views and controllers directories where the subdirectory name matches the snake-case version of the module (so the Intake module becomes an “intake” directory in this case) and put the view/controller files in there.
chrs for the post!
Another approach to multi-step forms problem is to handle everything completely on the client side……..and to then submit a fully prepared User to user#create. am curious as to your thoughts regarding this approach?
also, I’ve noticed that rather than creating two custom actions in the User controller – you’ve broken them out into completely separate controllers: UserProfiles and UserAccounts! Would be interested to know and understand how/why you’ve made that decision and the pros/cons of doing so?
That’s certainly an option, although I tend to avoid that because then I’m basically building an SPA and throwing away the benefits that Rails offers around validation, navigation, etc.
I separate the controllers because in my experience the logic often gets sufficiently complicated that putting it all in one controllers is too confusing.
For anyone who’s interested: might be worth also having a look at the simplicity of what basecamp have done with their multi-step sign up form: https://app.hey.com/sign_up/welcome
I went through their sign up process. It wasn’t transparent how they were saving the entered data between steps.
The process as I saw it was:
GET /sign_up/name/new
POST /sign_up/name/new
redirect to
GET /sign_up/email_address/new
POST /sign_up/email_address/new
redirect to
GET /sign_up/password/new
etc..
On each POST request, only the fields asked for on that page were submitted. I also couldn’t see a unique id in the path or form. That makes me assume that the data was kept in the session between steps.
Its a very need implementation of multistep single model forms 🙂
Can you share source code, pls?
Unfortunately there’s no source code for this other than what you see in the post. Maybe in the future though!
Great. Thanks for the detailed steps.
Worked for me
I tried to create a one bootstrap modal with the 2 multi-step pages but when the user_profile is created, not sure how to hook the user_account view.
Do you mean you’re trying to create a multi-step form inside a Bootstrap modal? Can you provide more detail about what you’re trying to do and what problems you’re encountering?
Thanks for sharing this.
awesome! very straight forward thanks so much!
Awesome content.
Thanks, Jason.
Hi thanks for the great post. Any chance you might consider showing how to make this a SPA using turbo frames and turbo streams. I’d be especially interested to see how you could add animations.
Hey Jason, awesome solution, thanks! So much cleaner than Wicked gem! 🙂
BTW, I’ve implemented it here, and noticed there’s a minor typo, at the:
views/intake/user_profiles/new.html.erb
You missed the value for the url parameter:
form_with(model: @user_profile, url: local: true
So the working version can be:
form_with(model: @user_profile, url: intake_user_profiles_path, local: true
Or, simply, works as well, relying on the path rails provides for the @user_profile model :
form_with(model: @user_profile, local: true
And, also, here’s a migration for the User model, that will be necessary at the user_accounts_controller.rb:
rails g scaffold User first_name last_name email password
Glad to contribute! Thanks!
André
Hi has something changed in recent Rails versions? This doesnt work nearly as well with Rails 7. The issue is that the model errors are no longer persisted for attr_accesor across rerenders. Therefore the error messages in the render :new after a failed validation are not shown. I have worked around it by also saving the errors in a custom instance variable, but I wonder when and why the behaviour changed.