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)