Inline form updates with Hotwire

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.

6 min read

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

  1. User interacts with the form, causing an event
  2. The Stimulus controller listens to the event and sets the src attribute on the turbo frame, which is the section we want to update
  3. Turbo notices the src attribute and fetches the frame content from the server
  4. The server returns the updated turbo frame with the same ID
  5. Turbo replaces the existing frame with the new frame, causing an in-form update, without submitting the form.
  6. 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.

In-form updates
In-form updates
💡
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:

  1. An action to listen to the events
  2. A target to access and update the section.

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:

  1. User interacts with the form, causing an event
  2. The Stimulus controller listens to the event and sets the src attribute on the turbo frame, which is the section we want to update
  3. Turbo notices the src attribute and fetches the frame content from the server
  4. The server returns the updated frame with the same ID
  5. 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.