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

Demonstration

Below is a recording of me interacting with a multi-step form using all the code shown above.

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.