justin․searls․co

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.

The ingredients

  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
  2. Routing: Add a route specifying a one-off controller action that will respond with a turbo stream
  3. 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
  4. Turbo Stream View: Create a turbo stream view for the action that invokes the partial
  5. Stimulus Controller: Create a generic Stimulus controller that can swap any model type when given a path, ID, and container
  6. 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. 🪄

The actions

Ingredients in hand, let's walk through each of the steps needed to go end-to-end with this feature.

1. Set Up the Rails Partial

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>

2. Add Routes

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").

3. Define the Controller Action

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.

4. Create the Turbo Stream View

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.

5. Define the Stimulus Controller

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:

gem "requestjs-rails"

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.

6. Wiring up the Stimulus Controller in the View

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.

That's it!

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.


Got a taste for hot, fresh takes?

Then you're in luck, because you can subscribe to this site via RSS or Mastodon! And if that ain't enough, then sign up for my newsletter and I'll send you a usually-pretty-good essay once a month. I also have a solo podcast, because of course I do.