Multi-page Forms ("Wizards") In Rails + Hotwire / Turbo Frames

Alright, there are two big points to this post:

  1. What I believe to be The Railsy-est (Railsiest?) way to do multi-page forms / wizards (for a single object)
  2. How to add Hotwire to that setup for maximum speed / UX

Multi-page Forms

First of all, what is a multi-page form (or 'wizard')? A multi-page form, also called a wizard (although I don't prefer this term), is any series of multiple steps / pages that allow a user to enter values for an object in disparate steps rather than being faced with one large form and all of the fields at once. It's the sort of thing where you click "Sign Up" on a site / app and they ask for your first / last name, then you click "Continue" and reach a new screen where they now ask for your address, then you click "continue" again and it asks for more data on the next screen etc. — with the important bit being that all of that data is ultimately stored on a single record in the database.

There are two major ways of accomplishing this task, but many of you probably already know about them so I'm going to push this explanation into "click for more" buttons:

The Javascript / Client-side Approach (Click for more)

The high level here is that the multi-page form workflow is all Javascript based. As a user enters fields and hits 'continue', no request to the server is actually being made, Javascript is just batching all the values up to be sent as one large POST once the user has completed the workflow. While this does appear to the server to be more RESTful, it has cons too. Notably that you need to re-write all of your validation code (the back end and front end now need to implement the same validations) and that you need to implement some kind of "go back and fix it" system in case the server rejects the POST because the value half-way through the wizard was wrong. Your Javascript needs to be able to parse out that error and get the user back to that page, fix the error, then get back to the POST at the end. That's a lot of plumbing!


The Server-side Approach (Click for more)

The high level on the server-side approach is that the server serves simple HTML forms to the user for each step in the workflow. As the user submits each step, the server stores that data in some way. Once the user completes the last step, the server should have all necessary data to move forward. I'll leave most of the pros and cons of this approach for below.

This article is about creating a good server-side approach for multi-page forms with Rails.

Let me open with this: I have high expectations for both the UX and technical sides of a multi-step form. Here's my list; here's what I want:

  • The code should be as DRY as possible. I don't want to have to write my field / attribute validations in two places
  • BUT I do want validations to happen on the step the user is on, not batched up until the end
  • I also want incoming parameters to be isolated to the particular fields available to the user on their current form step — I don't want open holes where a nefarious user could inject unvalidated values onto the model if that attribute is specified on the form step they're on
  • I don't really want to have the model store a 'status' column that persists the current state of each record a lá state machine
  • Whatever the workflow is, it shouldn't break the RESTfulness of the primary resource controller I've got a multi-step form for
  • On that note, I should have a completely normal experience working with a model in its 'full' form (e.g. a #create with all the parameters like from a JSON POST)
  • Data persistence mid-form-completion should live in the database, not in session or cache. Scalability and MVC principles both play roles here
  • With that, data-persistence across the steps should not just accumulate hidden inputs in the DOM. I know HTTPS is a thing, but I still don't always feel great about unnecessarily exposing user-data over-and-over
  • I don't want a controller for each step in the multi-step workflow. That feels excessive, like a ton of code duplication, and like something we can beat
  • I want this approach to be Hotwireable so that each step in the workflow can be encapsulated in Turbo Frame for SPA-like workflows
  • (Preference) I'd like users to be able to fill out multiple workflows in multiple tabs, not be tied to a single object's workflow via session as can sometimes happen
  • (Optional) Outside of the multi-step form workflow, I want to interact with my model normally in the rest of my application — I want to reasonably expect that if I ask for an instance of a Model, it will be fully filled-out

That all sounds easy, right? 😅

Well here's what I've got cooked up. This uses the Wicked Gem for automatic routing but most of the guts are stored in the model. This paradigm takes nods from Josh McArthur's 2014 article, "Multistep form validations with Rails and Wicked" but makes significant changes around routing and workflows.

For this example we're talking about Houses. This is the scaffold I used to generate the House model (and fields):

be rails g scaffold House address exterior_color interior_color current_family_last_name rooms:integer square_feet:integer

And here's the workflow: on the first page, users should tell me the address info (address and current_family_last_name) so I can know who to send the mail to. What mail? Who knows 🙃. After that, on a second form page/step, I want them to tell me about the house colors (interior_color and exterior_color). Maybe this is a painting company webapp? On the third page/step I want them to tell me about the inside (rooms and square_feet). Contrived enough example? Oh yes. But it serves the purpose. 😎

We're going to start by adding an enum and an attr_accessor to the model, House. It looks like this:

class House < ApplicationRecord
  enum form_steps: {
    address_info: [:address, :current_family_last_name],
    house_info: [:interior_color, :exterior_color],
    house_stats: [:rooms, :square_feet]
  }
  attr_accessor :form_step
end

Ignoring that we called the enum "form_steps" (which technically implies a view-ish concern in a model.. we could call it "build_steps" but that feels Java-y to me 😉), this enum does many things at once. It (1) names each specific step involved in building up this model via multiple steps. It (2) orders them top-to-bottom, giving bias to the intended way the model should be 'filled out'. And it (3) defines exactly which attributes on the model are intended to be contained by and updated in any given step. All three of these things are critically important moving forward, so ensure that you understand this enum 🙂. The attr_accessor is intentionally not a persisted field in the database, but it will provide the basis for knowing which step any given instance is on. Neat that we can do that without persisting the "current state" in the database. Yay! More on that later.

We need a bit more code in the model to handle the validations. We're going to start by adding this method:

def required_for_step?(step)
  # All fields are required if no form step is present
  return true if form_step.nil?

  # All fields from previous steps are required
  ordered_keys = self.class.form_steps.keys.map(&:to_sym)
  !!(ordered_keys.index(step) <= ordered_keys.index(form_step))
end

It's not as scary as it looks — it's a mechanism for determining if a particular validation is necessary based on the current object's form_step (the attr_accessor above) and a step in question. It makes more sense in context. Here's the full model:

class House < ApplicationRecord
  enum form_steps: {
    address_info: [:address, :current_family_last_name],
    house_info: [:interior_color, :exterior_color],
    house_stats: [:rooms, :square_feet]
  }
  attr_accessor :form_step

  with_options if: -> { required_for_step?(:address_info) } do
    validates :address, presence: true, length: { minimum: 10, maximum: 50}
    validates :current_family_last_name, presence: true, length: { minimum: 2, maximum: 30}
  end

  with_options if: -> { required_for_step?(:house_info) } do
    validates :interior_color, presence: true
    validates :exterior_color, presence: true
  end

  with_options if: -> { required_for_step?(:house_stats) } do
    validates :rooms, presence: true, numericality: { gt: 1 }
    validates :square_feet, presence: true
  end

  def required_for_step?(step)
    # All fields are required if no form step is present
    return true if form_step.nil?
  
    # All fields from previous steps are required
    ordered_keys = self.class.form_steps.keys.map(&:to_sym)
    !!(ordered_keys.index(step) <= ordered_keys.index(form_step))
  end
end

So given a bit more context of how required_for_step? is used, it's effectively a conditional governance around validations — only enabling the validations for a given form_step (and all those prior) if the object instance is actually on that form_step. If the object isn't on a form_step at all, all validations will execute normally. This is super important for making sure that our House model works "totally normally" across the rest of our application while running some partial validations during this multi-step form.

Okay, that covers the model. Let's talk controllers. We're going to need two. For starters, we're keeping almost everything stock on the HousesController that Rails generated for us. Rails generates great code. The less we mess with it, the better off we'll be 😆. The only thing we're changing is the new method, which is fine since the new method isn't actually part of the RESTful interface of the House resource — we're actually overriding it for the perfect reason. The new method exists to give users that don't already know how to formulate a proper RESTful POST (to create a full object in one request) an interface / view for doing just that. What are we doing with a multi-page form? Providing users with an interface to make subsequent requests to ultimately create an object in a RESTful way. That's exactly what new is for! We're just providing that interface in a multi-step format rather than a single (giant) form.

Okay, enough on that, here's the controller code:

class HousesController < ApplicationController
  before_action :set_house, only: %i[ show edit update destroy ]

  # GET /houses or /houses.json
  def index
    @houses = House.all
  end

  # GET /houses/1 or /houses/1.json
  def show
  end

  # GET /houses/new
  def new
    @house = House.new
    @house.save! validate: false
    redirect_to house_step_path(@house, House.form_steps.keys.first)
  end

  # GET /houses/1/edit
  def edit
  end

  # POST /houses or /houses.json
  def create
    @house = House.new(house_params)

    respond_to do |format|
      if @house.save
        format.html { redirect_to @house, notice: "House was successfully created." }
        format.json { render :show, status: :created, location: @house }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @house.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /houses/1 or /houses/1.json
  def update
    respond_to do |format|
      if @house.update(house_params)
        format.html { redirect_to @house, notice: "House was successfully updated." }
        format.json { render :show, status: :ok, location: @house }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @house.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /houses/1 or /houses/1.json
  def destroy
    @house.destroy
    respond_to do |format|
      format.html { redirect_to houses_url, notice: "House was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_house
      @house = House.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def house_params
      params.require(:house).permit(:address, :exterior_color, :interior_color, :current_family_last_name, :rooms, :square_feet)
    end
end

Again, I did nothing to the stock controller code that Rails generated other than adjust the new method to create and persist an empty model instance and redirect to the second controller. We'll note that the RESTful actions are still totally intact and available: index, show, create, update, and destroy. That's great, since it means our REST interface for the House resource is fully available.

Let's talk about that 'second' controller. This one is going to use Wicked and essentially handle the multi-step stepping-through-steps process. I've gone with a StepsControllers:: HouseStepsController naming / module scheme since my project has a couple of multi-step forms and grouping all the steps controllers together seemed convenient — these 'steps controllers' don't inherit from the main resource controller at all (HousesController in this case) so we can name them whatever we prefer. Here's the code:

module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      @house = House.find(params[:house_id])
      render_wizard
    end

    def update
      @house = House.find(params[:house_id])
      @house.update(house_params(step))
      render_wizard @house
    end

    private

    def house_params(step)  
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_path(@house)
    end
  end
end

Let's talk through that. For starters, we're pulling in Wicked and giving it the multi-step workflow by pulling directly from the model's definition. Then the show and update process follow the cycle Wicked sets up — rendering each step's view by looking for a (step_name).html.erb file in the views folder for this controller (we'll get there in a moment). We can note that house_params specifically only permits the attributes outlined in the model's form_steps enum for this step as to prevent nefarious users from injecting rogue attributes in to be saved without validation (nice!). Finally, we merge in the current form_step to hydrate that attr_accessor :form_step we defined in the model. Hydrating that form_step attr_accessor is how the validations get activated for only the specific form step we're processing!

But the magic here is how we're getting the "current step" — the step local variable above. As I mentioned before, this isn't a database-persisted value for each instance — rather it's actually coming directly from the URL path the user is on! We can credit Wicked for this fantastic touch and automatic wire-up (Wicked sets the step variable for us), but think about it this way: while our model contains the logic for how to move through the state machine that is the multi-step form, it's the user's browser that ultimately keeps the state of "which step they're on". And it's simple! It's just the URL. We don't need to store any extra information in the database to push the user to the correct steps and flows. Wicked just pushes them to the 'next step' from wherever they just submitted.

Speaking of URLs, this is a great time to talk about routes. Here's the routes I have for houses:

resources :houses do
  resources :steps, only: [:show, :update], controller: 'steps_controllers/house_steps'
end

And here we can clearly see that there are no adjustments to the :houses resource — its interface is still clean and stock. I can't emphasize enough how important that is for apps that serve content on multiple interfaces / media types! Anyway, that routing with the above controllers and Wicked give us a resulting workflow that looks like this:

  • GET /houses — Index of all houses
  • GET /houses/new — Creates a blank record (let's say ID 5) and redirects to /houses/5/steps/address_info per Wicked kicking off the workflow
  • GET /houses/5/steps/address_info— renders address_info.html.erb per Wicked
  • POST /houses/5/steps/address_info — updates the House then Wicked redirects to /houses/5/steps/house_info, the next step defined in the enum
  • GET /houses/5/steps/house_info — renders house_info.html.erb per Wicked
  • POST /houses/5/steps/house_info — updates the House then Wicked redirects to /houses/5/steps/house_stats, the next step defined in the enum
  • GET /houses/5/steps/house_stats — renders house_stats.html.erb per Wicked
  • POST /houses/5/steps/house_states — updates the House then Wicked notes the end of the workflow and redirects to house_path per the finish_wizard_path method.
  • Multi-step form complete!

And finally, let's cover the views real quick! Nothing really special about any of these other than their specific step-based naming and the form url being set to wizard_path, another helper set by Wicked. Since my steps controller is StepsControllers:: HouseStepsController, all of these views will be within the steps_controllers/house_steps/ views folder.

# views/steps_controllers/house_steps/address_info.html.erb

<%= form_with model: @house, url: wizard_path do |f| %>
  <% if f.object.errors.any? %>
    <div class="error_messages">
      <% f.object.errors.full_messages.each do |error| %>
        <p><%= error %></p>
      <% end %>
    </div>
  <% end %>

  <fieldset>
    <legend>Address Info</legend>

    <div>
      <%= f.label :address %>
      <%= f.text_field :address %>
    </div>

    <div>
      <%= f.label :current_family_last_name %>
      <%= f.text_field :current_family_last_name %>
    </div>

    <div>
      <%= f.submit 'Next Step' %>
    </div>
  </fieldset>
<% end %>

house_info.html.erb and house_stats.html.erb are basically the same as well, just having different labels and text_fields.

And that's it! Multi-step forms in Rails without compromises! Let's review my 'wants':

The code should be as DRY as possible. I don't want to have to write my field / attribute validations in two places

✅ The model contains most of the workflow, the user's browser path holds the "current step" state via `Wicked`, and validations are only written once!

BUT I do want validations to happen on the step the user is on, not batched up until the end

✅ Since we have validations-per-step, this is exactly what happens! `Wicked` won't redirect the user to the next step if there are validation errors, instead rendering the same step in place with the errors!

I also want incoming parameters to be isolated to the particular fields available to the user on their _current_ form step — I don't want open holes where a nefarious user could inject unvalidated values onto the model if that attribute is specified on the form step they're on

✅ Nailed it.

I don't really want to have the model store a 'status' column that persists the current state of each record a lá state machine

✅ The single-directional workflow allows the URL to _be_ the state of the 'current step'!

Whatever the workflow is, it shouldn't break the RESTfulness of the primary resource controller I've got a multi-step form for

✅ Possibly my favorite part. Interacting with the HousesController in a RESTful way is _fully_ unchanged from stock Rails 💯

On that note, I should have a _completely normal_ experience working with a model in its 'full' form (e.g. a #create with all the parameters like from a JSON POST)

✅ Indeed, a standard RESTful POST to /houses still hits the HousesController and all validations are executed / business occurs as normal

Data persistence mid-form-completion should live in the database, not in session or cache. Scalability and MVC principles both play roles here

✅ Check that! Since this process persists an empty model from the start then adds more data per-step, this is indeed a database-centric approach

With that, data-persistence across the steps should not just accumulate hidden inputs in the DOM. I know HTTPS is a thing, but I still don't always feel great about unnecessarily exposing user-data over-and-over

I don't want a controller for each step in the multi-step workflow. That feels excessive, like a ton of code duplication, and like something we can beat

✅ Pretty happy here too; instead of a controller for each step, we have one controller for all the steps. To add a new step we'd just need to change the enum in the model and add another appropriately-named view! Simple enough 😍

I want this approach to be Hotwireable so that each step in the workflow can be encapsulated in a Turbo Frame for SPA-like workflows

✅ We did that! Read on for the Hotwire wireup.

(Preference) I'd like users to be able to fill out multiple workflows in multiple tabs, not be tied to a single object's workflow via session as can sometimes happen

✅🟡 By explicitly using the empty House's ID in the URL / Routes, we did this. There is another option though; scroll down and read the "click to read more" section called "Routing / Nosy Neighbors / ID in URL"

(Optional) Outside of the multi-step form workflow, I want to interact with my model normally in the rest of my application — I want to reasonably expect that if I ask for an instance of a Model, it will be fully filled-out

🟡 Sort of. With what I've given above, calling any old House.last somewhere else in the app may give us one that is not fully completed / populated. That could be an issue. There is a solution though! Scroll down and read the "click to read more" section called "Completedness"

At the end of the day, I think this is a great combination of a number of tools and results in a multi-step form that's fairly easy to grok via the model's enum, form-step-aware validations, and named views. Having seen a number of other / different ways to implement multi-step forms in Rails, I think this one provides great tradeoffs all around. Food for thought!

Integrating Hotwire

Luckily this is actually the easy part! After installing hotwire-rails via bundle add hotwire-rails && bundle exec rails hotwire:install, we just need to wrap our step-named views in Turbo Frames. Since each view will be implementing the same-named frame, I went ahead and pushed it down to the Model as a constant:

class House < ApplicationRecord
  FORM_TURBO_FRAME_ID = 'house_multi_step_form'
  
  # everything else...
end

Adding Turbo Frames is as easy as adding a wrapper to my forms / step-named views:

# views/steps_controllers/house_steps/address_info.html.erb

<%= turbo_frame_tag House::FORM_TURBO_FRAME_ID do %>
  <%= form_with model: @house, url: wizard_path do |f| %>
    <% if f.object.errors.any? %>
      <div class="error_messages">
        <% f.object.errors.full_messages.each do |error| %>
          <p><%= error %></p>
        <% end %>
      </div>
    <% end %>

    <fieldset>
      <legend>Address Info</legend>

      <div>
        <%= f.label :address %>
        <%= f.text_field :address %>
      </div>

      <div>
        <%= f.label :current_family_last_name %>
        <%= f.text_field :current_family_last_name %>
      </div>

      <div>
        <%= f.submit 'Next Step' %>
      </div>
    </fieldset>
  <% end %>
<% end %>

and voila! Automatic Turbo Frames. The only catch here is that we need to add a target to the last step to force a Turbo Drive (e.g. full-page) navigation rather than just an in-frame navigation. The difference here is simply in the form_with tag. Since my last form step is :house_stats, I'm making this change only in house_stats.html.erb.

<%= form_with model: @house, url: wizard_path, data: { 'turbo-frame' => :_top} do |f| %>

The resulting data-turbo-frame="_top" will inform Turbo to follow the ensuing redirect as a full-page navigation rather than just a frame replacement. That's it! You're done 😀

NOTE: since Turbo Frames will walk the user through every stage of your multi-step form without actually changing the page URL, you may want to consider a more vague name for your first form step. That name will be the one shown in the browser's address bar for the entire workflow. Perhaps something like /houses/5/steps/capture or /houses/5/steps/details etc.

Tweaks / Variations

As noted above, there are a couple of other customizations to the multi-step form process you may be interested in pursuing:

Routing / Nosy Neighbors / ID in URL (Click to read more)
Routing / Nosy Neighbors / ID in URL

So this multi-step form methodology works by persisting the object data in a database record / object instance. That part we can't avoid. It should be noted though, there is a potential pitfall if your multi-step form is public-facing (non-authenticated users can fill it out) and you're using the ID-in-path routing I described above. That problem is 'nosy neighbor syndrome'. Think of it this way: if I randomly come to your form and start filling it out but see a path of .../4/steps/...., I may just go ahead and change that '4' in the URL to '3' and find someone else's data! That's not good.

There are a few options for working around this problem. If your form isn't accessible without authentication, just associate the empty object to that user when it's first created and verify that the current user indeed owns that object in the ___steps controller.

If your form is accessible without authentication, we can use session to store the ID of the newly-created record(s) for the current_user. This would look something like (in the HousesController):

def new
  @house = House.new completed: false
  @house.save! validate: false
  (session[:house_forms] ||= []) << @house.id
  redirect_to house_step_path(@house, House.form_steps.keys.first)
end

and in the StepsControllers:: HouseStepsController:

  def show
    @house = House.uncompleted.find(params[:house_id])
    redirect_to houses_path unless session[:house_forms].include? @house.id
    render_wizard
  end

  def update
    @house = House.uncompleted.find(params[:house_id])
    redirect_to houses_path unless session[:house_forms].include? @house.id
    @house.update(house_params(step))
    render_wizard @house
  end

It's important to note that this option continues to allow users to walk through multiple form steps for separate objects while on multiple tabs of the same browser.

Another option to avoid nosy-neighbors would be to use a random / hashed value as the object ID instead of a simple integer.

A final option would be to not use the object ID in the URL path at all, instead generating a new object and storing the object ID in the user's session. Unlike the suggestion using session above where the session was used as means of authorizing against the object ID requested in the URL path, omitting the object ID from the URL path and exclusively serving the forms for an object whose ID is stored in the session removes the ability for a user to walk through multiple forms concurrently in separate tabs. I'll leave this as an exercise for the reader.


Completedness (Click to read more)
Completedness

One of my personal goals in hashing out all of this functionality was to create a framework / workflow that rest of the Rails application would be completely unaware of. So much so that if I were to call out for a House.random elsewhere in the Rails app, I'd want to be confident that the resulting instance was fully validated and completed. I didn't want partially-completed instances muddying up my code / data throughout the app. There are very, very few times where I advocate for default_scopes because they almost always cause more pain than they're worth, but this is one time where I feel it's appropriate. Here's how I added functionality to isolate the partially-empty House objects away from the rest of my 'fully validated' records.

Add a column:

$> bundle exec rails g migration AddCompletedToHouses completed:boolean
$> bundle exec rails db:migrate

In the model:

class House < ApplicationRecord
  default_scope -> { where(completed: true) }
  scope :uncompleted, -> { unscope(where: :completed).where(completed: false) }

  # everything else...

In the top-level controller:

def new
  @house = House.new completed: false
  @house.save! validate: false
  redirect_to house_step_path(@house, House.form_steps.keys.first)
end

And in the steps controller:

module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      @house = House.uncompleted.find(params[:house_id]) # <-- add the .uncompleted.
      render_wizard
    end

    def update
      @house = House.uncompleted.find(params[:house_id]) # <-- add the .uncompleted.
      @house.update(house_params(step))
      render_wizard @house
    end

    private

    def house_params(step)  
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      @house.update! completed: true  # <-- add the .update!
      house_path(@house)
    end
  end
end

Now your app will be none-the-wiser that there's a multi-step form or partially-completed Houses! Any time you call for a House in your app outside of these two controllers, you can be sure that it's a completely-validated / fully-hydrated instance. Created either by a user walking through the multi-step form or by a POST to the create_path that gets validated all-at-once. Neato!

As an added side-effect, users that have completed the multi-step form won't be able to go through it again — instead just using the #edit path on the top-level controller! (Removing the .where(completed: false) from the :uncompleted scope would allow users to reach the multi-step form again once their record has been marked completed, but YMMV)

NOTE: Your workflow might not actually necessitate having a completed: true type setup — perhaps your first form page captures all the really necessary stuff (e.g. username / password) and all of the others are either optional fields or fields that you'll check for the presence of in subsequent processing. In that case, you don't need any of the completed plumbing above and can keep things a little simpler!



That's all! I hope that provides some guidance to the multi-step-forms / wizards-in-Rails world and helps clear up some questions. I can confirm that using this workflow + Turbo Frames is really fantastic. It works super smoothly and runs quite fast.

The demo repository will be uploaded tomorrow is here and may possibly be thrown on Heroku (free tier) soon — will update here.

Photo by @kellysikkema on Unsplash


Want to stay in the loop? 1 - 2 emails/month.

No spam. No data selling. Never.


Latest Blogposts

Trailing Slashes and Gatsby

The Ins and Outs of How Gatsby Does Slashes

April 16 2021

Tool Highlights: Typora

An app that allows Markdown to be my primary writing syntax

March 23 2021

Comparing JAMstack and Rails

Two different tools for two different jobs

March 20 2021