Creating Dynamic Forms with Hotwire and Turbo Stream in Rails 7:
A Complete Guide
In this tutorial, we’re diving into the world of Rails 7, Hotwire, and Turbo Stream to build dynamic forms. We’ll focus on a practical example for a car rental company, showcasing how to enhance user interfaces with seamless, interconnected form elements.
Problem Statement
Consider a Location model in a Rails application that has_many :car_brands, and each CarBrand has_many :models, which in turn has_many :colors. In a new or edit view for renting a car, the user should select a location first. Based on this selection, the available car brands at that location are displayed. Similarly, choosing a car brand will determine the available models, and finally, the model choice will influence the available colors. Rails “normal behavior” would refresh the view making it difficult to update only the element of the DOM element we want.-
Solution Overview
Our solution involves using Stimulus controllers and Turbo Frames - a feature already integrated into Rails 7 - to dynamically update the select boxes in our form without the need of a full content refresh. As the user selects a location, a fetch request is triggered to populate the car_brand select box. This pattern continues down to the selection of colors, ensuring each choice is dependent on the previous one.
Models Setup
# app/models/location.rb
class Location < ApplicationRecord
has_many :car_brands
end
# app/models/car_brand.rb
class CarBrand < ApplicationRecord
belongs_to :location
has_many :models
end
# app/models/model.rb
class Model < ApplicationRecord
belongs_to :car_brand
has_many :colors
end
# app/models/color.rb
class Color < ApplicationRecord
belongs_to :model
end
Building the Form View
To build the form we add the required inputs (name, email, etc), then we add the collection_select tag for Location, which is the starting point and the only one pre-populated.
Notice that we also added the “rental” stimulus controller in the form in order to handle the JavaScript from the view.
We also added data-target “location” and an action to the location select. We will explain that in the next steps.
#app/views/rentals/new.html.erb
<%= form_with url: rentals_path, method: :post, data: { controller: "rental" } do |form| %>
<%= form.label :first_name %>
<%= form.test_field :first_name, autocomplete: "given_name" %>
<%= form.label :last_name %>
<%= form.text_field :last_name, autocomplete: "family_name" %>
<%= form.label :address %>
<%= form.text_field :address, autocomplete: "address" %>
<%= form.label :email %>
<%= form.email_field :email, autocomplete: "email" %>
<%= form.label :location_id, 'Location' %>
<%= form.collection_select :location_id, Location.all, :id, :name,
include_blank: true,
data: { rental_target: "location", action: "rental#updateCarBrands" } %>
<div id="car_brands">
<%= render 'car_brands', car_brands: [] %>
</div>
<div id="models">
<%= render 'models', models: [] %>
</div>
<div id="colors">
<%= render 'colors', colors: [] %>
</div>
<%= form.submit "Rent Car" %>
<% end %>
Stimulus Controller. Initial Setup
In the Stimulus Controller we want to specify three target elements (data-target in the html), these are as follows: “location”, t, ‘carBrands’ and ‘models’. Actions will be named like updateCarbrands updateModels and updateColors.
updateCarbrands takes the value from the location target which is the one selected by the user, then we use that value to make a fetch request to an action that will be defined in the Rails Controller (update_car_brands).
The other actions and targets do exactly the same in a chain reaction.
Notice that each update method calls ‘fetchAndUpdate’. This function will receive html in the way hotwire-turbo sets it: with <turbo-tag> wrapping the code with the Turbo.renderStreamMessage(html) function adding the proper headings.
This html fragment is the one that will replace the empty select box with one populated with the options we need based on the previous select choice.
// app/javascript/controllers/rental_controller.js
import { Controller } from '@hotwired/stimulus';
import { Turbo } from '@hotwired/turbo-rails';
export default class extends Controller {
static targets = ["location", "carBrands", "models"]
connect() {
console.log("Rental controller connected");
}
updateCarBrands() {
const locationId = this.locationTarget.value;
this.fetchAndUpdate(`/update_car_brands?location_id=${locationId}`);
}
updateModels() {
const brandId = this.carBrandsTarget.value;
this.fetchAndUpdate(`/update_models?brand_id=${brandId}`);
}
updateColors() {
const modelId = this.modelsTarget.value;
this.fetchAndUpdate(`/update_colors?model_id=${modelId}`);
}
fetchAndUpdate(url) {
fetch(url, {
method: 'GET',
headers: {
Accept: 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': this.getMetaContent('csrf-token'),
'Cache-Control': 'no-cache',
},
})
.then(response => response.ok ? response.text() : Promise.reject('Response not OK'))
.then(html => Turbo.renderStreamMessage(html))
.catch(error => console.error('Error:', error));
}
getMetaContent(name) {
return document.querySelector(`meta[name="${name}"]`).getAttribute('content');
}
}
Rails Controller Methods
In the Rails Controller, let’s set up the actions to populate each select based on the fetch response. These actions will take the id from the params and find the CarBrands, Models and Colors depending on the ID of the previous choice.
Finally, render the turbo_stream replacing the content of the <div> with the corresponding ID with the content in the corresponding partial with the data we previously got from the Database, for example:
update_car_brands will get all the car brands from the DB whose location_id value is the same as the one we got from the params of the fetch request.
Then it will replace the content of the <div id=”car_brands”> with the partial _car_brands.html.erb with the correct data.
# app/controllers/rentals_controller.rb
class RentalsController < ApplicationController
def update_car_brands
car_brands = CarBrand.where(location_id: params[:location_id])
render turbo_stream: turbo_stream.replace('car_brands', partial: 'car_brands', locals: { car_brands: car_brands })
end
def update_models
models = Models.where(brand_id: params[:brand_id])
render turbo_stream: turbo_stream.replace('models', partial: 'models', locals: { models: models })
end
def update_colors
colors = Colors.where(model_id: params[:model_id])
render turbo_stream: turbo_stream.replace('colors', partial: 'colors', locals: { colors: colors })
end
end
Building Partials
There’s only missing part: we need to add content to the empty divs.
For that we are building partials that will have the updated content from the turbo stream.
Notice that the car_brands and model partial have data-target and actions. These are going to be rendered in the main form. Each partial calls its own action from the stimulus controller and has its own data-target
#app/views/rentals/_car_brands.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :car_brand_id, 'Car Brand' %>
<%= form.collection_select :car_brand_id, car_brands, :id, :name,
include_blank: true,
data: { rental_target: "carBrands", action: "rental#updateModels" } %>
<% end %>
# app/views/rentals/_models.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :model_id, 'Model' %>
<%= form.collection_select :model_id, models, :id, :name,
include_blank: true,
data: { rental_target: "models", action: "rental#updateColors" } %>
<% end %>
#app/views/rentals/_colors.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :color_id, 'Color' %>
<%= form.collection_select :color_id, colors, :id, :name,
include_blank: true,
data: { rental_target: "colors" } %>
<% end %>
Routing
Finally, we have to set up the routes for the fetch requests.
# config/routes.rb
Rails.application.routes.draw do
# Other routes
get 'update_car_brands', to: 'rentals#update_car_brands'
get 'update_models', to: 'rentals#update_models'
get 'update_colors', to: 'rentals#update_colors'
end
Conclusion
This comprehensive guide illustrates how to create a dynamic, interactive form in Rails 7 using Hotwire and Turbo Stream. This method enhances the user experience by providing a responsive and interconnected form, ideal for complex scenarios like our car rental service example. By following these steps, developers can implement similar dynamic features in their own Rails applications.
This guide merges the structured approach of my initial response with the detailed implementation steps, offering a complete and instructive tutorial for creating dynamic forms in Rails 7.