Hotwired Modals

  • April 28, 2022
  • Jon Yurek
  • 6 min read

We can use Hotwire -- specifically, Stimulus and Turbo -- to create some modals that present a nice, dynamic user experience. And they can do this while staying in a mutli-page app structure that Rails is so good at.

First off, we need to know that, with Turbo, you can load a page with a turbo-frame element and Turbo will take the element from the newly loaded page and inject its contents into the current page. It works just like frames and iframes work, but... way better.

So, what we're talking about here is an index page and an edit page -- oh, and a small Stimulus controller to pop up the modal. The index page lists out your Resources and has an empty modal section. The edit page looks exactly like the index page, but has an edit form in the modal section.

For the purpose of this example, let's call the Resource a "Gadget", just so it doesn't have a name that's already a core Rails concept.

On the index page, you have your Gadgets, and you want to list your Gadgets in a cool table (for however cool a table can be, but I digress). You'll likely have something like this in your app/views/gadgets/index.html.erb:

<% @gadgets.each do |gadget| %>
  <table id="gadgets-table">
    <tbody>
      <tr>
        <td><%= gadget.name %></td>
        <td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
      </tr>
    </tbody>
  </table>
<% end %>

Ok, there we go. Now we have an index that displays our Gadgets and a link that will load the edit page and replace the content of the modal via Turbo. Plus, also, a nice container for the modal itself. Gotta have that.

Similarly, we need the edit page. That will look very very similar, but with an important difference.

<% @gadgets.each do |gadget| %>
  <table id="gadgets-table">
    <tbody>
      <tr>
        <td><%= gadget.name %></td>
        <td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
      </tr>
    </tbody>
  </table>
 <% end %>

<div id="modal" data-controller="modal">
  <turbo-frame id="modal-content">
    <%= form_with model: @gadget do |form| %>
      <%= form.text_field :name %>
      <%= form.submit "Save" %>
    <% end %>
  </turbo-frame>
</div>

The difference, as you probably noticed, is that the modal has a form in it, set to edit the specified Gadget. Importantly, the data-turbo-frame attributes on the links let Turbo know to load the page and swap out the modal-content frame. So when the user clicks on the link in the table, the edit form's contents will just pop into that turbo-frame. You can see this if you did it right now, because we don't have any CSS to make this look like a modal, or to hide the modal.

Oh, right! CSS! Ok, let's get on that. Put this inside app/assets/stylesheets/modal.css (or wherever you're putting your CSS):

#modal {
  background: rgba(#002045, 0.85)
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
}

#modal.visible {
  display: flex;
  align-items: center;
  justify-content: center;
}

#modal-content {
  width: 50%;
  background: #fff;
}

Now the modal will look and act like a modal. This is the last part I mentioned at the start: The small Stimulus controller that watches for changes. Here's where we add the ModalController that the data-controller="modal" attribute on the modal container alluded to. This will go in app/javascript/controllers/modal_controller.js.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["open"]

  openTargetConnected() {
    this.open()
  }

  openTargetDisconnected() {
    this.close()
  }

  open() {
    this.element.classList.add("visible")
  }

  close() {
    this.element.classList.remove("visible")
  }
}

This works because Stimulus controllers have a number of lifecycle callbacks. In this case, we want the callbacks that activate when a target is connected and disconnected (that is, added to or removed from the children of the controller element). A target is marked with an attribute: data--target="". We have the ModalController above, and it has an `open` target, so the attribute would be data-modal-target="open". When a DOM node is either added that already has this attribute, or the attribute is added to an existing element, the openTargetConnected method on the ModalController is called.

This means that when Turbo loads the edit page and swaps in the modal, the ModalController will see the open target and give the whole thing the visible class.

Cool, so now what happens when we submit? Well, turbo-frames are limited to one interaction per page load, basically. And since what we want to do is close the modal and update the contents of the index, we'll need something a little more flexible: Turbo Streams.

Turbo Streams let you batch up multiple changes into one response. And, despite their name, you don't have to use them in a streaming context. You can return them from an action, same as anything else.

In our GadgetsController's update action, we need to save the Gadget we just changed in the browser. Then, instead of rendering, we redirect to the index. The index's turbo-stream response will do what we need it to: it will replace the modal with an empty modal, and it will replace the table with a table that contains the new Gadget's information.

This change to the index action is pretty small. It's using already-existing functionality:

respond_to do |format|
  format.html
  format.turbo_stream
end

If you are using the turbo-rails gem, this format will be created for you. This, then, should be what app/views/gadgets/index.turbo_stream.erb looks like (and you can, of course, extract whatever you want into partials):

<turbo-stream action="replace" target="modal-content">
  <template>
    <turbo-frame id="modal-content"></turbo-frame>
  </template>
</turbo-stream>

<turbo-stream action="replace" target="gadgets-table">
  <template>
    <table id="gadgets-table">
      <% @gadgets.each do |gadget| %>
        <tr>
          <td><%= gadget.name %></td>
          <td><%= link_to "Edit", edit_gadget_path(gadget), data: { "turbo-frame": "modal-content" } %></td>
        </tr>
      <% end %>
    </table>
  </template>
</turbo-stream>

Now, when the user submits the form and it saves successfully, the update action will redirect to index and index will render the above turbo-stream. Turbo will replace the modal-content which will make Stimulus close the modal, and it will update the table to reflect the new value of the
Gadget.

The best part of all of this is that this is just using existing web paradigms. And if the user (for whatever reason) doesn't have javascript enabled, this will Just Work in exactly the same manner (albeit a little slower) because of the progressive enhancement on top of the multi-request cycle. And with Javascript, it never reloads the page.