Recipe: Swapping out a model div with Turbo Streams and Stimulus
Rails + Hotwire is very
capable of dynamic behavior like replacing a component in the DOM by sending
HTML over-the-wire in response to a user action, but the fact it requires you to
touch half a dozen files in the process can make the whole thing feel daunting.
Rails itself has always been this way (with each incremental feature requiring a
route, model, controller, view, etc.), but I've been using it long enough that I
sometimes forget that—similar to learning a recipe—I originally needed months of
intentional practice to internalize and gain comfort with the framework's most
routine of workflows.
So, like a recipe card, here is a reusable approach to swapping out a <div>
rendered by a Rails partial with a turbo stream whenever a user selects an
alternate model from an input (specifically, a select
) using Turbo 8, Stimulus
1.3, and Rails 7.1.
- Partial: Extract a partial to be rendered inside the element you wish to
replace, so that both your view and your turbo stream can render the same
markup for a given model
- Routing: Add a route specifying a one-off controller action that will
respond with a turbo stream
- Controller Action: Define an action that takes your model ID and the DOM
element's ID and responds with a turbo stream to update the element's
contents
- Turbo Stream View: Create a turbo stream view for
the action that invokes the partial
- Stimulus Controller: Create a generic Stimulus controller that can swap
any model type when given a path, ID, and container
- View: Wire up the Stimulus controller to the view's select box and the to-be-replaced element
That's it, 6 key ingredients. If you're curious, step 5 contains the most magic
flavoring. 🪄
Ingredients in hand, let's walk through each of the steps needed to go
end-to-end with this feature.
First, create a partial that you want to render inside the <div>
. Let's assume
we want users to be able to change out a generic model named Item
, which has
a conventional ItemsController
.
In that case, let's place a partial that renders the details about an item alongside
the controller's views, in _detail.html.erb
:
<!-- app/views/items/_detail.html.erb -->
<div>
<%= item.title %>
<!-- Other item stuff… -->
</div>
Next, we'll add the necessary route for the detail
action:
# config/routes.rb
resources :items do
get :detail, on: :collection
end
This will define a path helper detail_items_path
, which works out to
"/items/detail"
.
Note that I threw this on the :collection
so that our stimulus controller can
more easily specify the URL via query parameters instead of interpolating a
fancier member route (e.g. "items/42/detail"
).
With the route defined, we'll add a simple controller action that only responds to turbo stream requests.
Here's what that might look like:
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
def detail
@dom_id = params[:dom_id]
@item = Item.find(params[:id])
end
end
This dom_id
param might throw you off at first, but it's important to keep in
mind that unique HTML IDs are the coin of the realm in
Turbo-land. You'll see how it
gets set later, in step 5.
To finish the route-controller-view errand, we'll create a view for the detail
action,
with the turbo_stream.erb
extension instead of html.erb
:
<!-- app/views/items/detail.turbo_stream.erb -->
<%= turbo_stream.update @dom_id do %>
<%= render partial: "detail", locals: { item: @item } %>
<% end %>
Because both the turbo stream and the original view need to render items in
exactly the same way, detail.turbo_stream.erb
view responds by rendering the
_detail.html.erb
partial. If you inspect the HTML that comes over the wire,
you'll see that only the turbo stream tag containing this partial is
transferred, which often means barely more data is transferred than had we
implemented this as a single-page JavaScript by making a similar HTTP request
for JSON.
In order for users' selections to have any effect, we need JavaScript. We could
write a Stimulus controller that's coupled specifically to this Item
model,
but it's no more work to make it generic, which would allow us to reuse this
functionality elsewhere in our app. So let's do that.
You can do this the hard way by using the browser's built-in fetch
API to
construct the URL, set the Accept
header to text/vnd.turbo-stream.html
, and
replace the element's innerHTML
in the DOM, but that's easy to screw up (in
fact, I screwed it up twice while writing this). So instead, I'd recommend pulling
in the requestjs-rails gem, by first
chucking it in your Gemfile
alongside any other front-end related gems:
Here's the final Stimulus controller. Deep breath, as I haven't explained all
this yet:
// app/javascript/controllers/model_swap_controller.js
import { get } from '@rails/request.js'
import { Controller } from '@hotwired/stimulus'
export default class ModelSwapController extends Controller {
static targets = ['container']
static values = {
path: String,
originalId: String
}
swap (event) {
const modelId = event.currentTarget.value || // Value from input action
event.detail?.value || // Value from custom event (e.g. hotwire_combobox)
this.originalIdValue // Fallback to original value if input value is cleared
get(this.pathValue, {
query: {
id: modelId,
dom_id: this.containerTarget.id
},
responseKind: 'turbo-stream'
})
}
}
That get
function from @rails/request.js
handles all the housekeeping you
might hope it would. When I switched to it, the fact it worked the
instant I plopped it in gave me Dem Magic Vibes that keep me coming back to
Rails 18 years in.
This controller also contains two values and a target:
path
value: this is just a URL, which we'll set to our
intentionally-parameter-free detail_items_path
originalId
value: this is the Item
ID that was first rendered when the
page loaded. By having this available as a fallback, we'll be able to gracefully
handle the user choosing a blank option from the select
by restoring the
original item
container
target: this is the DOM element containing the partial we're going
to swap out. Note that it must have a unique id
attribute, which we're
including in our request to the server as dom_id
If this doesn't make perfect sense, I recommend wiring it up anyway and getting
it working first, then debugging to inspect the values in motion.
Finally, we'll visit the original view from which the _detail.html.erb
partial
was initially extracted.
Right off the bat, you might notice that I like to use content_tag
whenever I
need to specify numerous attributes with Ruby expressions, as it requires
far fewer <%=%>
interpolations than specifying a literal <div>
:
<!-- app/views/items/show.html.erb -->
<%= content_tag :div, data: {
controller: "model-swap",
model_swap_path_value: detail_items_path,
model_swap_original_id_value: @item.id,
} do %>
<%= collection_select :item, :id, Item.all, :id, :title,
{include_blank: true},
{data: {action: "model-swap#swap"}} %>
<div id="<%= dom_id(@item, "detail") %>" data-model-swap-target="container">
<%= render partial: "detail", locals: { item: @item } %>
</div>
<% end %>
The above will probably look immediately familiar to anyone who's done a lot
of work with Stimulus before and utterly arcane otherwise. Helping you sort out
the latter is beyond the scope of this article, though. Ask ChatGPT or something.
The only thing in the above template that wasn't completely preordained by the
first 5 steps was the id
attribute of the wrapping div element, so I'll
explain that here. For illustration purposes, I set the container div to
dom_id(@item, "detail")
(which would work out to something like
"detail_item_42"
) to give an example of something that's likely to be unique,
but in truth, the most appropriate ID will depend on what's going on in the
broader page. For example, in the UI that inspired this blog post, I am allowing
users to replace any of a variable array of items across 3 options, so my IDs
are based on those indices, like option_2_item_4
, as opposed to the database
ID of any models. All that really matters is that the ID be unique.
Pulling off functionality like this with Turbo and Stimulus feels extra delightful, I
think, if you (like so many of us) spent the last decade assuming that this kind
of snappy, dynamic behavior would require a front-end JavaScript framework that would live
forever and keep track of a duplicated copy of the app's state. Instead, because
the server-side rendered view can draw the entire page without any JavaScript
involved, any client-side changes we introduce to the state of the DOM can
operate on attributes first defined by the view, keeping the entire source of
truth of the current application state in one place (the DOM) instead of two (a
server database and in-memory JavaScript objects).
Anyway, when it works, it's great. And when the lego bricks aren't snapping
together for whatever reason, it's infuriating. Which maybe makes Hotwire the
most Rails-assed extension to the framework since Rails itself. If you find
yourself losing a bunch of time to what seem like trivial naming issues, just
know that you're not alone. This stuff takes practice to get used to.
If you've worked through this guide, hopefully you have a functioning feature
that you can continue iterating on. If you stumbled over any errata above,
please let me know.