How to do multi-step forms in Rails

by Jason Swett,

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.

24 thoughts on “How to do multi-step forms in Rails

      1. Wayne

        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?

        Reply
        1. Jason Swett Post author

          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.

          Reply
  1. Vítor Ribas

    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.

    Reply
    1. Jason Swett Post author

      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.

      Reply
        1. Jason Swett Post author

          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.

          Reply
  2. Ben

    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?

    Reply
    1. Jason Swett Post author

      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.

      Reply
    1. Lang

      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.

      Reply
  3. Roe Lot

    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.

    Reply
    1. Jason Swett Post author

      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?

      Reply
  4. Mitchell Gould

    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.

    Reply
  5. André Garcia

    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é

    Reply
  6. Augusto

    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.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *