Hotwired Modals
Published: 2022-04-29 19:19:52

We can use [Hotwire][hotwire] -- specifically, [Stimulus][stimulus] and [Turbo][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][inject]. It works just like `frame`s and `iframe`s 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`:

```
<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>
<div id="modal" data-controller="modal">
  <turbo-frame id="modal-content"></turbo-frame>
</div>
```

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 similar, but with an important difference.

```
<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>
<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 [targeting]. 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][lifecycle]. 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-<controller-name>-target="<target-name>"`. 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-frame`s 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][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][turbo-rails], 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.

[stimulus]: https://stimulus.hotwired.dev/
[turbo]: https://turbo.hotwired.dev/
[optimization]: https://github.com/hotwired/turbo-rails/blob/da5eb4b49e0ae379e713ed9302d07e563770f4d0/app/controllers/turbo/frames/frame_request.rb
[lifecycle]: https://stimulus.hotwired.dev/reference/lifecycle-callbacks
[inject]: https://turbo.hotwired.dev/handbook/frames
[targeting]: https://turbo.hotwired.dev/handbook/frames#targeting-navigation-into-or-out-of-a-frame
[streams]: https://turbo.hotwired.dev/handbook/streams
[turbo-rails]: https://rubygems.org/gems/turbo-rails
[hotwire]: https://hotwired.dev/

_Learn more about how The Gnar [builds Ruby on Rails applications](https://www.thegnar.com/software-development/ruby-on-rails-development)._