Using Hotwire for Inline Form Updates Without Form Submission
Sometimes you need dynamic content on the form, where parts of the form update based on the user input. This post shows how to use Hotwire's Turbo and Stimulus libraries for in-place form updates by loading data from the server, without submitting the form, which happens only once, at the end.
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
srcattribute on the turbo frame, which is the section we want to update - Turbo notices the
srcattribute 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:
💡 Turbo Frames allow specific sections of a page to be dynamically updated without reloading the entire page. By using the 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.
💡 Remember: The pink text is still part of the form, which we're updating based on the data fetched from the server.
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.
💡 If you want a quick primer of Stimulus.js with small projects, check out my practical-stimulus series. Start with: How to Capture 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
srcattribute on the turbo frame, which is the section we want to update - Turbo notices the
srcattribute 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?
Sign up for my newsletter
Let's learn to become better developers.
Comments (4)
Clever trick! It’s a great idea to combine Stimulus with lazy-loading turbo frames. Does it update again if the select is changed more than once? Also note: the default event for ‘select’ is ‘change’, so you could simply write ‘refresh#updateCount’. See https://stimulus.hotwired.dev/reference/actions#event-shorthand Finally, it would be best to place the src value in the HTML instead of the stimulus controller, using ‘data-refresh-src-value’ on the stimulus root element, define it using ‘static values { src: String }’, and read it with ‘this.srcValue’. Doing that will remove coupling and make your Stimulus controller much more versatile. See https://stimulus.hotwired.dev/reference/values
Thanks Goulven, all excellent suggestions ✌️ appreciate the feedback! > Does it update again if the select is changed more than once? Yes, as shown in the GIF above, the Stimulus controller fires every time you change the select, triggering a refresh. > the default event for ‘select’ is ‘change’, so you could simply write ‘refresh#updateCount’ Correct, I just prefer to be more obvious, so I know which events we're listening to. > it would be best to place the src value in the HTML instead of the stimulus controller. Yep, that's what I ended up doing, since I use it in multiple places. Thought that would be out of scope for this post, but might write about it someday. Thanks, again, for taking the time to leave feedback!
Great point about using the src attribute. I've read the Hotwire/Turbo docs many times, and I didn't have a use case for src until now. Thanks!
Glad to hear, Nicholai!