Turbo-powered Dynamic Fields

Engineering Insights

October 9, 2025
Jon Yurek
#
Min Read
Turbo-powered Dynamic Fields

The Hotwire front-end suite of libraries can make for a robust end-user experience without a whole lot of things you have to do manually. It is very much centered around the main paradigm of HTTP, which is the request-response cycle. This puts it at odds with React-style web programming, which is actually (far too often) a distributed system rather than just a view layer. Turbo lets your server do your rendering, which is why you went to all the trouble of writing HTML in a Rails app in the first place, right?

Ok, here's a quick example, because we need to anchor some theory: You have an app that lets people sign up for lessons. Your organization offers a lot of lessons in a lot of locations, so you need to let your users pick where they are before picking a lesson. Let’s make a form that creates a Signup object, which will model the relationship between a Lesson and a Location (and probably a User, but we don’t need to worry about that for this example).

To create a Signup, we'll need a select dropdown to choose a Location and then another to choose from the Lessons that are available there. Buuut... how to do that? We could load the options in a JSON document and insert them into the select, but that means doing JavaScript work. Well, sure, that's not hard, but it means more code, and more code means more liability.

On the other hand (and to uncover the buried lede), what if you didn't have to write more than a single line of JS? We can make use of Turbo and our own preexisting ERB to handle this.

You can submit a form using a button element, of course. But while the form element specifies the method and action, your button doesn't have to care about that! It can specify other ones, using the formmethod and formaction attributes.

This button now becomes your refresh button. If you give it a formmethod of GET and a formaction that loads the new action, you can render the form again, but with different data! With Turbo, it'll drop it right in place of the existing one. You can hide the button if you want (you don't need to if you don't mind seeing it, and having the user press it means zero manual JS).

A word of caution, however! Since this is submitting a form with a GET request, all the parameters will be sent in the URL and will be visible in both the URL bar (so it’ll be in the browser history), and the HTTP logs on the server (including analytics). If you ever use this technique, be sure to strip any sensitive fields like passwords or credit card numbers before the form is submitted. But also note that this means the password and/or credit card will appear to be emptied out when the form changes. This does mean more JavaScript, but I find this to be a fair tradeoff for information security.

And so if you have this ERB:

# app/view/signups/new.html.erb

<%= turbo_frame_tag @signup do %>
  <%= form_for @signup do |form| %>
    <%= form.button "Refresh", id: "refresh", formmethod: "GET", formaction: new_signup_path %>
    <%= form.select :location, Location.all.map { [it.name, it.id] } %>
    <% if @signup.location %>
      <%= form.select :lesson, (@signup.location&.lessons || {}).map { [it.name, it.id] } %>
    <% end %>
  <% end %>
<% end %>

and this action in your SignupsController:

def new
  @signup = Signup.new(params[:signup])
end

then, you'll render HTML that looks like this:

<turbo-frame id="new_signup">
  <form class="new_signup" id="new_signup" action="/signups" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="<AUTH_TOKEN>" autocomplete="off">
    <button name="button" type="submit" id="refresh" formmethod="GET" formaction="/signups/new" hidden="hidden">Refresh</button>
    <select onclick="this.form.refresh.click()" name="signup[location_id]" id="signup_location_id"><option value="1">Boston, MA</option>
      <option value="2">Lowell, MA</option>
      <option value="3">Manchester, NH</option>
    </select>
  </form>
</turbo-frame>

…and you have a form with a button that refreshes the form using the newly entered values.

Now, if you want the refresh to be automatic (and hidden), you can make this change below. It is the only JS you need to write for this feature to work. The rest is handled by Turbo. It's in an onChange handler to keep things small and highlight how little JS you need -- if I were doing this in a real app, I would stick this in a Stimulus controller or something like it.

# app/view/signups/new.html.erb:4

-  <%= form.button "Refresh", id: "refresh", formmethod: "GET", formaction: new_signup_path %>
-  <%= form.select :location_id, Location.all.map { [it.name, it.id] } %>
+  <%= form.button "Refresh", id: "refresh", formmethod: "GET", formaction: new_signup_path, hidden: true %>
+  <%= form.select :location_id, Location.all.map { [it.name, it.id] }, {}, onClick: "this.form.refresh.click()" %>

So, now, when we pick a location from the select, Turbo will automatically reload the form with the lesson options for that location made available. All with only one line of handwritten JavaScript!

Related Insights

See All Articles
Engineering Insights
Turbo-powered Dynamic Fields

Turbo-powered Dynamic Fields

Render parts of your views so your users get the right options at the right times, and do it with as little front-end effort as necessary.
Engineering Insights
React in Rails, One Component at a Time

React in Rails, One Component at a Time

It seems like we should be able to drop React components into our views on an ad hoc basis using Stimulus, which is exactly what we will do.
Engineering Insights
A Near Miss: How I Froze My Website by Adding a Generated Column

A Near Miss: How I Froze My Website by Adding a Generated Column

Do avoid freezing your database, don't create a generated column. Add a nullable column, a trigger to update its value, and a job to backfill old data.
Previous
Next
See All Articles