The Hotwire-Rails summit, or interactive multi-step forms at peak UX

Picture this: one day your product, which was built with Ruby on Rails in a canonical HTML-first (Hotwire) fashion, gets an “off-world” feature request, namely, building a highly-customizable and amazingly-interactive user interface. You stare at Figma mockups scratching your head and mulling an unspeakable question: “Is the Renaissance at an end? Should we reach for React now?” Before you abandon ship, let me share the tips and tricks we devised while building a sophisticated multi-step form that rivals any SPA experience for one of our clients, SumIt.
First, some context: SumIt helps families keep their finances under control by providing a user-friendly and human-focused interface to their accounts, transactions, swear jars as well as providing insights about what’s going on via a number of built-in reports. In fact, the new feature that raised a question mark on the “tech-stack choice question” was their custom reports builder.
SumIt is built with Ruby on Rails and has been successfully following the HTML-over-the-Wire way with a balanced combination of Turbo, Stimulus, View Components on one side and a handful of third-party JavaScript components on the other. There’s no need to reimplement everything in Hotwire, there are robust JS solutions on the market (and Turbo Mount to integrate them into Hotwire).
From the very beginning, it was clear to the team that this new feature would require special attention, so they reached out to us for advice and help. We accepted the challenge, and thus began yet another attempt upwards at pushing Rails and Hotwire beyond their limits.
More on the challenge
The “Custom Reports” feature was designed with a very high level of flexibility for the end users: picking and organizing accounts, adding various aggregations and groupings, configuring the visual representation, and so on.
It’s not surprising that a multi-step form (or a wizard) mechanic was chosen here. At each step of the process, a user can configure a particular aspect of the report using various form controls and see a live preview with every change. Words can’t explain the feature better than a demo, so let’s share a video of the final look and feel:
The custom Report wizard in action
Remember the “staring at Figma mockups and scratching the head” piece from the beginning of this post? Well, that was me. My first suggestion was to go with React and Inertia Rails. This was natural: you see a bunch of local (browser-controlled) state—you think “React, TypeScript, Nano Stores…“.
Don’t get me wrong, I believe that you can build pretty much anything with Hotwire, but there is a high risk of ending up re-inventing React et al. only in a much worse form. To minimize these risks, we tried to answer the following questions first:
-
How much client state do we really need, or can we control everything from the server? Yes, an HTML form is enough to keep the state.
-
How much new Stimulus/JS code will we need to write? Not a lot, we can heavily re-use existing Stimulus controllers.
-
Does our component library contain all the required UI elements? No, but we have View Components, TailwindCSS, and Lookbook set up, so adding new components is not a big deal.
So, “Hotwire is the way”, and we cheered and started to work. Let me share the practical tips and tricks we used to minimize the aforementioned risks and end up with a good UI/UX in the end.
Tip #0: Modeling multi-step forms in Rails
Building multi-step forms the Rails Way is a topic that deserves its own article (or, maybe, a chapter in a book 😉). Over the years, I came up with a recipe that hasn’t failed me yet. The key ingredients are this:
- No fancy (wizardy) gems. Stick to Rails concepts.
- A single form object to encapsulate the entire UI wizard logic (using a state machine-like abstraction to describe the flow).
- Familiar controller actions resembling typical operations on Active Record models.
That latter requirement means that if we decide to replace the multi-step wizard with a single form (of dozens of inputs), we won’t need to change the client-server interface or the corresponding controller’s action code.
Here’s an excerpt from the controller we had:
def update
@report_form = CustomReportForm.with(report:).from(params.require(:report))
if @report_form.save
# The final wizard's step has completed
redirect_to custom_report_path(report)
else
# Validation failed or moved to the next step
render :new, status: (@report_form.errors.none? ? :created : :unprocessable_entity)
end
end
If you want to learn more about this approach, please check out my keynote at Kaigi on Rails 2024:
Tip #1: Morphing for the win
Let’s move on to the UI part of the challenge.
As you can see from the demo above, the wizard steps consist of two areas: a control (form) panel on the left and a report preview on the right. Whenever a user interacts with the control panel and update the form-under-construction settings, we must update the preview to reflect those changes.
Since we went HTML all-in, the preview update is controlled by the server: a user changes a setting, we auto-submit the form in the background, and the server issues a Turbo Streams “replace” action to update the form contents. Profit!
Even though we tried to avoid any local state as hell, we couldn’t (and didn’t want to) eliminate it completely. Replacing the form HTML with a new version looked laggish. Thus, we decided to take a look at another strategy for updating the page contents—DOM morphing.
Side-by-side comparison of Turbo navigation without (left) and with (right) DOM morphing
In the video above, you can see the difference between full a HTML swap and morphing. Let’s dig deeper into how the right-hand side works.
First of all, we’ve added the method: "morph"
to our Turbo Stream template:
<%= turbo_stream.replace :reporting_custom_wizard, method: "morph" do %>
<%= render "form", report_form: @report_form %>
<% end %>
Unfortunately, that was just the beginning. Morphing doesn’t solve all the problems auto-magically, you need to teach existing UI components (e.g., Stimulus controllers) to take it into account.
Let’s start with the collapsible checkbox groups. The “open” or “closed” state is local, and the server doesn’t (and shouldn’t) know about it. Their interactivity is controlled by the “checkbox-select-all” Stimulus controller from the stimulus-components library. Still, it turned out that this controller doesn’t play nicely with morphing. No problem, we fixed it (and submitted a PR).
Many third-party Stimulus controllers are not morphing-ready. Please, make sure your Stimulus library takes morphing into account.
That said, just making the controller work wasn’t enough: we also needed to persist its local state (backed by CSS classes and HTML data attributes) between morph updates. How to do that?
One option could be to introduce some JS-world state (not just HTML) to the controller and listen to the turbo:morph-element
event to invalidate the state. This approach would require us to update every controller having some local state. However, given that the project relied on many third-party Stimulus controllers, that wasn’t really an option for us.
Luckily, Turbo gives us the ability to inject our logic into its lifecycle by subscribing to Turbo-specific events. Actually, we also have the turbo:before-morph-attribute
event that allows you to control a single HTML attribute morphing logic—exactly what we needed!
So, we introduced a new data-turbo-morph-permanent-attrs
attribute that can be used to specify the list of HTML attributes that shouldn’t be touched by the morphing process. This is how we use it in the Accordion component, for example:
<div data-controller="accordion"
data-accordion-open-value="<%= open? %>"
data-turbo-morph-permanent-attrs="data-accordion-open-value"
data-action="turbo:morph-element->accordion#contentChanged">
<!-- ... -->
</div>
Below is the full implementation—feel free to drop it into your project and make morphing smarter:
// Add support for preserving HTML attribute during morphing by
// specifying the list of attributes to preserve (space separated) as the `data-turbo-morph-permanent-attrs` attribute.
// Example:
// <div data-turbo-morph-permanent-attrs="data-collapsible-open-value class"></div>
addEventListener("turbo:before-morph-attribute", (event) => {
if (!event.target.dataset.turboMorphPermanentAttrs) return;
const { attributeName } = event.detail;
const regex = event.target._permanentAttrsRegex ||=
new RegExp(`\\b(${event.target.dataset.turboMorphPermanentAttrs.split(/\s+/).join('|')})\\b`);
if (regex.test(attributeName)) {
event.preventDefault();
}
});
addEventListener("turbo:before-morph-element", (event) => {
// Support data-turbo-morph-permanent attribute, which unlike regular
// turbo-morph-permanent checks that the target element is the same permanent element (by ID if any) preventing the permanent element from staying on the page.
if (
event.target.hasAttribute('data-turbo-morph-permanent') &&
event.detail.newElement &&
event.detail.newElement.hasAttribute('data-turbo-morph-permanent') &&
(!event.target.id || event.target.id === event.detail.newElement.id)
) {
return event.preventDefault();
}
// We must disable permanent attributes if the target element has no such data attribute (since the target for before-morph-attribute is the original element)
if (event.target.dataset.turboMorphPermanentAttrs) {
if (event.target.dataset.turboMorphPermanentAttrs !== event.detail.newElement?.dataset?.turboMorphPermanentAttrs) {
delete event.target.dataset.turboMorphPermanentAttrs;
delete event.target._permanentAttrsRegex;
}
}
});
A couple more things to note:
-
We added a custom
data-turbo-morph-permanent
control attribute that fixes the problem we had with the built-indata-turbo-permanent
attribute with morphing (stale elements on the page). -
Note that we also added the
turbo:morph-element->accordion#contentChanged
Stimulus action to the Accordion controller; this kind of additional attention to morphing might be necessary if you want to invalidate the Stimulus-backed component after potential changes to its element children nodes (in this particular case, we were required to recalculate the content height).
The preview component of the report form is built with the AG Grid component wrapped in a Stimulus controller. The datagrid is rendered by JavaScript—that’s why we had to use the data-turbo-morph-permanent
attribute. To provide a smooth and animated preview updates, we reused the same AG Grid instance and used its API to update the grid configuration and data (passed from the server via an HTML attribute):
export default class extends Controller {
static values = {
// ...
reportData: Object,
};
reportDataValueChanged() {
if (this.gridApi) {
this.gridApi.setGridOption("rowData", this.reportDataValue.row_data);
// ...
}
}
}
Using values and value change callbacks are one of most useful techniques for making your Stimulus controllers morphing-ready.
Tip #2: Dealing with “nested” forms
The trickiest wizard step (that eluded our initialed estimation 😁) was the time intervals constructor. Just take a look at it:
Time intervals constructor
As you can see, for each time interval type, we have a mini form to create new intervals. So, we have a form within form situtation. Did you know that having nested <form>
tags isn’t allowed? (That is, the outer one wins.) The idea of having one form to rule them all came within a hair’s breadth from fiasco.
So, if we can’t have nested forms, maybe we should fallback to fetch()
and submit these interval forms programmatically? We could create a dedicated controller (IntervalsController
), add an action per interval type (#period
, #previous
, #range
, etc.), and use the same Turbo Stream template to update the form. I started bringing this idea to life and quickly found myself lost in all the new code that we needed just to overcome some web-standards limitation.
As already mentioned, we used a form object on the Rails side to process all the wizard input. The good thing about form objects is that they’re very flexible (unlike web standards). Want to embed a nested form behavior? No problem, just add the corresponding attribute(-s):
class CustomReportForm < ApplicationForm
attribute :name
attribute :description
# more wizard attributes
# time interval attributes
attribute :new_period_interval
attribute :new_previous_interval
attribute :new_fixed_interval
# the activation attribute
attribute :new_interval_type
# Add a new time interval from the corresponding sub-form
def build_new_interval
case new_interval_type
when "period"
intervals << CustomReport::Interval::Period.build(year: new_period_interval[:year], period: new_period_interval[:period], breakdown: new_period_interval[:breakdown]).to_params
when "previous"
intervals << CustomReport::Interval::Previous.build(count: new_previous_interval[:count].to_i, period: new_previous_interval[:period]).to_params
when "fixed"
intervals << CustomReport::Interval::Fixed.build(start_date: new_fixed_interval[:start_date], end_date: new_fixed_interval[:end_date], name: new_fixed_interval[:name]).to_params
end
end
end
The key trick here is the “new_interval_type” activation attribute: this attribute is only present in the client submission data if one of the nested forms was submitted. But we have a single form element with all the inputs, how do we implement such activation mechanism? No magic, just HTML.
Let’s look at a time interval form template (verbose Tailwind styles omitted):
<%= form.fields_for :new_previous_interval, allow_method_names_outside_object: true do |f| %>
<%= f.number_field :count, required: true, value: 1, min: 1 %>
<%= f.select :period, available_periods, {include_blank: false} %>
<%= button_tag name: "#{form.object_name}[new_interval_type]", value: "previous" do %>
<%= render_svg("icons/plus" %>
Add
<% end %>
<% end %>
First, we use form.fields_for
for building nested form attributes. The allow_method_names_outside_object: true
is important here, since we didn’t bother to port the accept_nested_attributes_for
functionality into our ApplicationForm
class.
Then, the button used to submit the nested form has a name and value attributes set—that’s how we activate this form submission! In a form, the button and its value is only submitted if the button itself was used to submit the form. How cool is that?
HTML is full of tricks like this. For example, there’s also the formaction
attribute for buttons that you can use to specify a different submission URL or the formmethod
to change the HTTP method used by the form.
Bonus: Polishing with View Transitions
Now, the cherry on the top is transition animations. Let’s take a look at the wizard again:
Custom Report wizard in action
The back and forth sliding animations are powered by the browser-native View Transition API and the turbo-view-transitions library. The library helps control which elements must be transitioned using the HTML attributes, and when they should be. We also use it to support view transitions for Turbo Streams actions as follows:
import { shouldPerformTransition, performTransition } from "turbo-view-transitions";
document.addEventListener("turbo:before-stream-render", (event) => {
if (shouldPerformTransition()) {
const fallbackToDefaultActions = event.detail.render;
event.detail.render = (streamEl) => {
if (streamEl.action == "update" || streamEl.action == "replace") {
const [target] = streamEl.targetElements;
if (target) {
return performTransition(
target,
streamEl.templateElement.content,
async () => {
await fallbackToDefaultActions(streamEl);
},
{ transitionAttr: "data-turbo-stream-transition" }
);
}
}
return fallbackToDefaultActions(streamEl);
};
}
});
After that, we can add the data-turbo-stream-transition="<transition-name>"
attribute to any element to animate its transition using the specified transition name only on Turbo Stream updates (and only if both old and new HTML contents have the same value for this attribute).
However, in our case that was not enough: our transitions are directional. That means, we need to specify a different transition name (say, “slide-right” or “slide-left”) depending on whether we clicked on the “Next” or “Previous” button.
The server-side form object already knows about the direction: we have a dedicated wizard_action
attribute that is populated by the browser form using the same button-name-value trick we used in nested forms. So, here is the #view_transition
method in the corresponding view component:
def view_transition_name
case report_form.wizard_action
when "autosubmit" then "none"
when "back" then "wizard-back"
else "wizard"
end
end
And we use it in the ERB template as follows:
<div data-turbo-stream-transition="<%=view_transition_name %>" data-reporting--custom-form-target="transition">
</div>
Note that we also specify that this DOM element is a target for a Stimulus controller—that’s where the second part of the puzzle sits:
handleSubmit(event: SubmitEvent) {
// We want to update data-turbo-stream-transition attribute depending on the button pressed,
// so the transition is in the right direction.
// We need to do this before the form is submitted
const button = event.submitter as HTMLButtonElement;
if (!this.hasTransitionTarget) return;
const el = this.transitionTarget;
if (button.value === "back") {
el.dataset.turboStreamTransition = "wizard-back";
} else if (button.value === "autosubmit") {
delete el.dataset.turboStreamTransition;
} else {
el.dataset.turboStreamTransition = "wizard";
}
}
The handleSubmit
function is attached to the form “submit” event, so we can update the data attribute right before the submission request. Here we use the event.submitter
property to know which button was used to submit the form—again, pure HTML/DOM, nothing else!
Directional transitions are quite popular, so we’re thinking of incorporating support for them into the turbo-view-transitions
library (so you don’t need to copy the Stimulus code above). Stay tuned!
Closing reflections
We faced some skepticism regarding the tech stack choice for this feature in the beginning. So, now that it’s been implemented, what are my reflections about that?
Hacking around Turbo, morphing and orchestrating third-party libraries with Stimulus felt more like necessary evil or dirty magic than a best practice. Luckily, the project already had most of that implemented and properly isolated, so we were able to reuse the existing code as much as possible.
Aligning with a project’s practices and tools should play a decisive role in choosing the right tool for the job.
Working within the Hotwire/HTML constraints encouraged us to think more about the client-server communication. We managed to localize all the building logic within a single HTML form (and the corresponding form object). HTML forms are universal; we can leave the backend code mostly intact if we decide to go with Inertia or something else in the future. I doubt that if we went with a React/API approached we wouldn’t end up with a bunch of API endpoints, serializers, and the like.
This is a thing I like the most about Ruby on Rails: you always have options, and those options do not require you to start from scratch if you decide to switch from one to the other. Today, you can be totally fine with Hotwire; tomorrow, you may decide that it’s time to mount some JavaScript sprinkles; a year from now, you may consider going with Inertia (and still keep your backend code almost as is!).