This article was originally published on Rails Designer
Over the years there have been many an article that suggest shorter forms have a positive impact on user experience (UX). Aside from a better experience, having shorter forms also means collecting less data on your users. Good for your users, good for you.
Rails has had the wicked gem for a long time that helps set this up. But to me, this kind of feature is not something I would outsource to gem. More importantly it is really not that difficult and by fully owning the code, you are free to tweak it to make it fit your app.
In this article I am laying out how I would go about a multi-step form used for onboarding new users to my app. Something like this:
You can see each screen, after the welcome screen, is just one input field (that is sometimes skippable). The data I want to get:
- workspace name;
- use-case (this could be used to set up the correct dummy/example template in their dashboard);
- invite co-workers;
- theme preference.
Clean, easy and to the point.
The code for this article can be found in this GitHub repository, be sure to check it out as I left some code below for brevity.
I assume you have a Rails app ready to go. The example uses Tailwind CSS, but that is not required. Let's get started by setting up the routes and a basic controller:
# config/routes.rb
resource :onboardings, path: "get-started", only: %w[show create]
# app/controllers/onboardings_controller.rb
class OnboardingsController < ApplicationController
def show
@onboarding = Onboarding.new
end
def create
Onboarding.new(onboarding_params).save
redirect_to root_path
end
end
The singular resource pattern works well here since users only need one onboarding process. The path "get-started" makes for a friendly URL.
A form object to store/redirect the data
Form objects are perfect for (more) complex forms that don't map directly to a database table:
# app/models/onboarding.rb
class Onboarding
include ActiveModel::Model
include ActiveModel::Attributes
include Onboarding::Steps
attribute :workspace_name, :string
attribute :use_case, :string
attribute :coworker_emails, :string
attribute :theme, :string
def save
return false if invalid?
ActiveRecord::Base.transaction do
workspace = create_workspace
add_usecase_to workspace
add_invitees_to workspace
add_theme_preference
end
end
private
def create_workspace
puts "Creating workspace: #{workspace_name}"
end
def add_usecase_to(workspace)
puts "Set Workspace template for use case: #{use_case}"
end
# …
end
ActiveModel::Model
provides validations, form helpers, and other Rails model features without a database table. Each method handles a specific part of the data processing, keeping the code organized.
Extend the onboarding class with concerns
The Step class enables dot notation access to properties and works with Rails helpers like dom_id
:
# app/models/onboarding/step.rb
class Onboarding::Step
include ActiveModel::Model
attr_accessor :id, :title, :description, :fields
def to_param = id
end
The Steps module defines all steps in the onboarding process:
# app/models/onboarding/steps.rb
module Onboarding::Steps
def steps
data.map { Onboarding::Step.new(it) }
end
private
def data
[
{
id: "welcome",
title: "Welcome to my app 🎉",
description: "In just a few we'll get your workspace up and running",
fields: []
},
{
id: "workspace",
title: "Workspace Name",
fields: [
{
name: :workspace_name,
label: "Workspace Name",
type: :text_field,
placeholder: "My Awesome Workspace"
}
]
}
# …
]
end
end
This approach makes it easy to modify the steps or add new ones without changing the view code.
Partials for each input field
Each field type has a dedicated partial to keep the view code organized:
<!-- app/views/onboardings/fields/_text_field.html.erb -->
<%= form.text_field field[:name], class: "w-full px-3 py-1 border border-gray-300 rounded-sm", placeholder: field[:placeholder] %>
<!-- app/views/onboardings/fields/_select.html.erb -->
<%= form.select field[:name], field[:options], { include_blank: field[:include_blank] }, { class: "w-full px-3 py-1 border border-gray-300 rounded-sm" } %>
Bringing it all together
The main view template brings everything together:
<!-- app/views/onboardings/show.html.erb (partial) -->
<main data-controller="onboarding">
<!-- Step indicators -->
<%= form_with model: @onboarding do |form| %>
<% @onboarding.steps.each_with_index do |step, index| %>
<div
id="<%= dom_id(step) %>"
class="<%= class_names("grid place-items-center h-dvh max-w-3xl mx-auto", { hidden: index.zero? }) %>"
data-onboarding-target="step"
>
<div class="grid gap-3 text-center">
<h2 class="text-2xl font-semibold text-gray-900">
<%= step.title %>
</h2>
<!-- Fields and buttons -->
</div>
</div>
<% end %>
<% end %>
</main>
The h-dvh
class sets the height to 100% of the dynamic viewport height, ensuring each step fills the screen. The form uses the onboarding model, which automatically maps fields to the right attributes. Also note how nice that API is: @onboarding.steps
(thank you concerns!). 🧑🍳
Stimulus controller for navigation steps
Then a small Stimulus controller to manage step navigation and the progress indicators:
// app/javascript/controllers/onboarding_controller.js
export default class extends Controller {
static targets = ["step", "indicator"]
static values = { step: { type: Number, default: 0 } }
stepValueChanged() {
this.#updateVisibleStep()
this.#updateIndicators()
}
continue(event) {
const currentStepId = event.currentTarget.dataset.step
const currentIndex = this.stepTargets.findIndex(step =>
step.id === `onboarding_step_${currentStepId}`
)
this.stepValue = currentIndex + 1
}
#updateVisibleStep() {
this.stepTargets.forEach((step, index) => {
const invisible = index !== this.stepValue
step.classList.toggle("hidden", invisible)
})
}
}
The stepValueChanged
callback automatically runs whenever the step value changes, updating the UI. This Stimulus feature, that I wrote about before, eliminates the need for manual event handling. And of course, the use of private methods (the #
prefix), which I talk about in the book JavaScript for Rails Developers.
And now all is left is to add redirect_to onboardings_path
in your SignupsController#create
. 🎉
Other things you might want to add is some better validation/errors feedback (or add sane defaults) and a way to keep track if a workspace/user has completed onboarding (the Rails Vault is great for this).
And there you have it. A clean, good looking onboarding flow. The beauty of this set up is that you can easily copy these files over and make the required tweaks needed for your (next) app.
Top comments (0)