Ruby on Rails

Enriching slow Rails forms with Turbo Frames


Turbo and Stimulus (as part of Hotwire) offer great solutions to make asynchronously loading parts of your views very easy. With Eager-Loading Frames it only takes a few lines of code to make a part of the page load after the page has rendered. We can use them to enrich the user experience of a simple form that can benefit from loading external data asynchronously.

Imagine having a form which depends on a third party API to show possible values for a select dropdown. A simple implementation loads the values in the controller and then renders the form:

# app/controllers/examples_controller.rb
def new
  @example = Example.new
  @options = ExternalApi::Options.slow_method
end
<%# app/views/examples/new.html.erb %>
<%= form_with(model: @example) do |form| %>
  <%= form.collection_select :option, @options, :id, :name %>
  <%= form.button "Create" %>
<% end %>

With Turbo Frames it’s quite easy to asynchronously load the dropdown in your form, by replacing the collection_select with a Turbo Frame:

<%# app/views/examples/new.html.erb %>
<%= form_with(model: @example) do |form| %>
  <turbo-frame id="example-options" src="<%= options_example_path %>">
    Loading...
  </turbo-frame>
  <%= form.button "Create" %>
<% end %>

This eagerly loads the frame from the URL in the src attribute and replaces it’s content automatically. For it to work we need to add a route:

# config/routes.rb
resources :examples do
  collection do
    get :options
  end
end

And a controller method that loads the data from the external API and remove that call from the method that now just renders the form:

# app/controllers/examples_controller.rb
def new
  @example = Example.new
end

def options
  @options = ExternalApi::Options.slow_method
end

Finally you need to add the partial that gets rendered by the options method and Turbo Frame uses to insert the select. It matches the Turbo frame to replace based on the id:

<%# app/views/examples/options.html.erb %>
<turbo-frame id="example-options">
  <%= collection_select :example, :option, @options, :id, :name %>
</turbo-frame>

Note that you can’t use the form block anymore, since this is rendered in isolation without the context of the original form. This means you have to use the regular form helpers.

That’s all there is and it works great: you now have a fast form that loads the slow part later. But there is one major downside: the form can be submitted before the Turbo Frame has been loaded.

We can add a small Stimulus controller to take care of that. Turbo Frames trigger events and we can hook into those to disable the submit button until the form has been completely loaded.

What this little controller does is disable the button when the controller connects to your HTML, and enable it again when the frame has been rendered.

// app/javascript/controllers/framed_form_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["button", "frame"]

  connect() {
    this.buttonTarget.disabled = true

    this.frameTarget.addEventListener("turbo:frame-render", () => {
      this.buttonTarget.disabled = false
    })
  }
}

To connect the Stimulus controller to the form we only need to add a few data tags:

<%# app/views/examples/new.html.erb %>
<%= form_with(model: @example, data: { controller: "framed-form" }) do |form| %>
  <turbo-frame id="example-options" src="<%= options_example_path %>" data-framed-form-target="frame">
    Loading...
  </turbo-frame>

  <%= form.button "Create", data: { "framed-form-target": "button" } %>
<% end %>

You now have upgraded a slowly loading form to load the slow parts asynchronously and made sure it can only be submitted after it has been loaded. It’s a nice example of how with a few lines of code Turbo and Stimulus can do the heavy lifting without reaching for full fledged frontend frameworks.