On a recent client project, I had to build a dynamic form where users could select a customer from a dropdown and immediately see that customer's details update within the form, without having to submit the form or refreshing the page. The goal was to allow users to continue filling the form while reflecting their choices in real time.
To be specific, I wanted to update specific sections of the form with data fetched from the server, based on user interactions (e.g., select an option from a dropdown). The challenge: the form couldn’t be submitted during these updates, and I didn't want to use nested forms, which is forbidden anyway, because it causes unpredictable behavior. What's more, since regular turbo frames work in response to link clicks or form submissions, I couldn't directly use them.
Eager loaded turbo frames provided a nice solution for this dynamic behavior, and I wanted to explore this approach in this post. As always, I'm not saying that this is the best solution. If you know a better way to do this, I'd love to hear it; feel free to email me or leave a comment below.
TL;DR
- User interacts with the form, causing an event
- The Stimulus controller listens to the event and sets the
src
attribute on the turbo frame, which is the section we want to update - Turbo notices the
src
attribute and fetches the frame content from the server - The server returns the updated turbo frame with the same ID
- Turbo replaces the existing frame with the new frame, causing an in-form update, without submitting the form.
- User continues updating the form
Eager Loaded Frames
I stumbled across this solution while reading the Turbo docs, specifically, the eager loaded frames. If you haven't come across them yet, here's the essence:
src
attribute on a Turbo Frame, you can trigger the frame to load content from the server whenever necessary, which is perfect for dynamic form updates.Typically, you use eager-loaded frames to reduce the first-load time for the page, by making further requests after the page is loaded. However, I realized that you could just convert a regular turbo frame into an eager-loaded one by setting the src
attribute, which is pretty cool!
Let’s break down how to use this to solve our in-form update challenge. We'll simplify the problem to display the number of outstanding tasks when you select a task category.
Step 1: Set Up the Form with a Stimulus Controller
To dynamically update sections of the form in response to user actions, we'll need two things:
For this, we need to first attach a Stimulus controller to the form. This controller will handle user interactions and trigger updates based on user input.
Let's add a new Stimulus controller called refresh_controller
.
$ bin/rails generate stimulus refresh
Next, we'll connect the form to the Stimulus controller:
<%= form_with model: @task, data: { controller: "refresh" } do |form| %>
...
<% end %>
The data-controller
attribute links the form to the Refresh
Stimulus controller, which will handle updating parts of the form dynamically.
Step 2: Attach Event Listener to the Dropdown
For this example, we have a dropdown for selecting a task category, and we want to update the number of outstanding tasks based on the selected category. So we'll listen for the change
event on the select
element.
<div class="my-3">
<%= form.select :category,
Task.categories.keys.map { |category| [category.titleize, category] },
{ prompt: "Select a category" },
class: "w-full p-2 rounded-md",
data: { action: "change->refresh#updateCount" }
%>
</div>
<div class="mt-3 mb-7">
<%= render "outstanding_tasks", number_of_tasks: @number_of_tasks %>
</div>
In this example, the select
dropdown has an event handler (change->refresh#updateCount
) that triggers the updateCount
method in the Stimulus controller when the user selects a category.
The form also includes a partial (outstanding_tasks
) that displays the number of outstanding tasks, which we want to update dynamically based on the selected category.
Step 3: Using Turbo Frames to Handle Updates
We wrap the part of the form that should be updated dynamically inside a Turbo Frame. The Turbo Frame will be refreshed whenever new data is fetched from the server.
Here's the _outstanding_tasks.html.erb
partial:
<%= turbo_frame_tag "outstanding-tasks", data: { refresh_target: "outstandingTasks" } do %>
<p class="text-pink-600 font-bold text-center"># of outstanding tasks: <%= number_of_tasks %></p>
<% end %>
The turbo_frame_tag
generates a frame that can be independently updated without affecting the rest of the page. The Turbo Frame is also targeted by the Stimulus controller, allowing us to refer to this element in the Stimulus controller to trigger updates.
Step 4: Writing the Stimulus Controller
This is the essence of the solution, where we set the src
attribute on the frame, to force a dynamic update.
The Stimulus controller is responsible for setting the src
attribute on the Turbo Frame. When the src
is set, Turbo will automatically fetch new content from the specified URL and update the frame.
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="refresh"
export default class extends Controller {
static targets = ["outstandingTasks"]
updateCount(e) {
this.outstandingTasksTarget.src = `/tasks/outstanding_tasks?category=${e.target.value}`;
}
}
In this controller, the updateCount
method is triggered when the user selects a category from the dropdown. It sets the src
attribute on the Turbo Frame, which tells Turbo to fetch updated content from the server. The content is fetched from a specific endpoint (/tasks/outstanding_tasks
) based on the selected category.
Step 5: Add the Rails Controller and Route
On the server side, we need a corresponding Rails action that returns the number of outstanding tasks based on the selected category. This is done by adding a new action to the TasksController
and defining the route.
resources :tasks do
get :outstanding_tasks, on: :collection
end
class TasksController < ApplicationController
def new
@task = Task.new
@number_of_tasks = 0
end
def outstanding_tasks
@number_of_tasks = rand(1..10)
render partial: "outstanding_tasks", locals: { number_of_tasks: @number_of_tasks }
end
end
In the outstanding_tasks
action, we calculate the number of outstanding tasks and render the partial with the new data. This partial will be injected into the Turbo Frame, replacing the previous content.
Step 6: Partial for Dynamic Content
Finally, the partial that renders the number of outstanding tasks can be reused for both initial rendering and dynamic updates:
<%= render "outstanding_tasks", number_of_tasks: @number_of_tasks %>
To recap: when you select the category, this will trigger the Stimulus action which will set the src
attribute on the Turbo Frame, causing it to load the contents of the frame from the Server. The server returns the Turbo Frame with the same ID, which replaces the existing content of the Frame, effectively causing a in-form refresh.
Here's the abstract sequence of events:
- User interacts with the form, causing an event
- The Stimulus controller listens to the event and sets the
src
attribute on the turbo frame, which is the section we want to update - Turbo notices the
src
attribute and fetches the frame content from the server - The server returns the updated frame with the same ID
- Turbo replaces the existing frame with the new frame, causing an in-form update, without submitting the form.
To wrap up, using Turbo Frames and the src
attribute, you can fetch and update content on the fly without reloading the page or submitting the form. This lets you to dynamically update sections of a form in Rails using Hotwire. It's a handy pattern in any scenario where you need dynamic updates within a form.
Not only this improves the user experience but also keeps the interactions smooth and responsive. What do you think?
That's a wrap. I hope you found this article helpful and you learned something new.
As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I reply to all emails I get from developers, and I look forward to hearing from you.
If you'd like to receive future articles directly in your email, please subscribe to my blog. Your email is respected, never shared, rented, sold or spammed. If you're already a subscriber, thank you.