The rebuilding turbo-rails tutorial is out!
View new course →
← Back to the list of chapters

Build modals with Hotwire in Rails (Turbo Frames + Stimulus)

View source code
Published on
In this article, we will learn to implement modals with Turbo Frames and Stimulus that are reusable in your whole Ruby on Rails application.

Sketching our Rails application without modals

In this article, we will learn how to create modals with Hotwire.

We will start by creating a standard CRUD application and then enhance it with our modals.

Let's imagine we want to build a to-do list application with Ruby on Rails and Hotwire. The Items#index page will look like this:

All itemsNew itemFirst itemSecond item<div id="items"><turbo-frame id="new_item">data-turbo-frame="new_item"
Sketch of the Items#index page

We will also need a page to create new items, so we will need a Items#new page that could look like this:

New itemItem's nameSave<turbo-frame id="new_item">
Sketch of the Items#new page

As our application is built with Turbo Rails, when clicking on the "New item" link, the new item form is extracted from the Items#new page and inserted within the Turbo Frame with the corresponding id on the Items#index page:

All itemsNew itemFirst itemSecond item<div id="items"><turbo-frame id="new_item">Item's nameSave
Sketch of the Items#index page with the form inserted

When we create a valid item, it is prepended to the list of items, and we remove the form from the Items#index page:

All itemsNew itemThird itemSecond item<div id="items"><turbo-frame id="new_item">data-turbo-frame="new_item"First item
Sketch of the Items#index page with the created item

Let's first build this application without any modals. We will learn how to add them later.

Coding our Rails application without modals

First, we need an Item model that has a name field that must be present:

# app/models/item.rb

class Item < ApplicationRecord
  validates :name, presence: true

  scope :ordered, -> { order(id: :desc) }
end

We can then create the corresponding Rails controller that only has the #index, #new, and #create actions:

# app/controllers/items_controller.rb

class ItemsController < ApplicationController
  def index
    @items = Item.all.ordered
  end

  def new
    @item = Item.new
  end

  def create
    @item = Item.new(item_params)

    if @item.save
      respond_to do |format|
        format.html { redirect_to items_path }
        format.turbo_stream
      end
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def item_params
    params.require(:item).permit(:name)
  end
end

The Items#index view lists the items. It also contains a link to the Items#new page that drives the Turbo Frame with id "new_item":

<%# app/views/items/index.html.erb %>

<main class="container">
  <div class="col-md-6 offset-md-3">
    <div class="d-flex justify-content-between align-items-center my-4">
      <h1 class="h3">
        All items
      </h1>
      <%= link_to "New item",
                  new_item_path,
                  class: "btn btn-primary",
                  data: { turbo_frame: "new_item" } %>
    </div>

    <%= turbo_frame_tag "new_item" %>

    <div id="items" class="d-flex flex-column gap-3">
      <%= render @items %>
    </div>
  </div>
</main>

Each item looks like this:

<%# app/views/items/_item.html.erb %>

<div class="border rounded p-3">
  <%= item.name %>
</div>

Note: We use Bootstrap in this tutorial for styling purposes, so this is where the CSS classes come from!


On the Items#new page, we need a Trubo Frame with an id of "new_item" that wraps the form so that Turbo can extract the content of this Frame and insert it on the Items#index page:

<%# app/views/items/new.html.erb %>

<main class="container">
  <%= link_to "Back to items", items_path %>

  <h1 class="my-4">
    New item
  </h1>

  <%= turbo_frame_tag "new_item" do %>
    <%= render "items/form", item: @item %>
  <% end %>
</main>

The form is a classic Rails form that also contains an alert in case there are some errors:

<%# app/views/items/_form.html.erb %>

<% if item.errors.any? %>
  <div class="alert alert-danger">
    <%= item.errors.full_messages.to_sentence %>
  </div>
<% end %>

<%= form_with model: item, class: "row g-3 mb-3" do |f| %>
  <div class="col-auto flex-grow-1">
    <%= f.label :name, class: "visually-hidden" %>
    <%= f.text_field :name, placeholder: "Todo item", class: "form-control" %>
  </div>

  <div class="col-auto">
    <%= f.submit "Save", class: "btn btn-primary" %>
  </div>
<% end %>

Last but not least, when the form submission is successful, we need a Turbo Stream view to prepend the created item to the list of items and clear the content of the Turbo Frame that contains the form on the Items#index page:

<%# app/views/items/create.turbo_stream.erb %>

<%= turbo_stream.prepend "items", @item %>
<%= turbo_stream.update "new_item", "" %>

Our application should now work as described in the sketches above. It's now time to start thinking about how we will implement our modals with Hotwire.

Sketching our modal with Turbo Frames and Stimulus.js

Now that we have a working application, we would like to make the creation of new items happen in a modal. Let's rename our "new_item" Turbo Frame to "modal" on the Items#index page and move it to the bottom of the page:

All itemsNew itemFirst itemSecond item<div id="items"><turbo-frame id="modal">data-turbo-frame="modal"
Sketch of the Items#index page with the modal frame

We should also update our Items#new page to ensure the Turbo Frame around the form also has an id of "modal":

New itemItem's nameSave<turbo-frame id="modal">
Sketch of the Items#new page with the modal frame

Now, when clicking on the "New item" button, the new item form is inserted on the Items#index page in the "modal" Turbo Frame.

All itemsNew itemFirst itemSecond item<div id="items"><turbo-frame id="modal">Item's nameSave
Sketch of the Items#new page with the new item form inserted

The only thing left to do is to create a Stimulus controller that:

  • Opens the modal when a Turbo Frame is inserted inside it.
  • Closes the modal when a valid form is submitted.

All itemsNew itemFirst itemSecond item<div id="items"><turbo-frame id="modal">Item's nameSave<div class="modal" data-controller="modal">
Sketch of the Items#index page with the modal opened thanks to Stimulus

Let's learn to do this in the next section.

Coding our modal with Turbo Frames and Stimulus.js

First, let's add the "modal" Turbo Frame to the application's layout. While adding the modal to the layout is not strictly necessary, it's a good way to ensure there is always an empty modal on every page. That way, we ensure that our modal pattern will work on every page of our application.

Let's add our modal markup and the corresponding Turbo Frame in the layout:

<%# app/views/layouts/application.html.erb %>

<body>
  <%= yield %>

  <div class="modal" tabindex="-1">
    <div class="modal-dialog">
      <div class="modal-content">
        <%= turbo_frame_tag "modal" %>
      </div>
    </div>
  </div>
</body>

Let's now update the Items#index and Items#new pages markup to use the "modal" id instead of the "new_item" one:

<%# app/views/items/index.html.erb %>

<main class="container">
  <div class="col-md-6 offset-md-3">
    <div class="d-flex justify-content-between align-items-center my-4">
      <h1 class="h3">
        All items
      </h1>
      <%= link_to "New item",
                  new_item_path,
                  class: "btn btn-primary",
                  data: { turbo_frame: "modal" } %>
    </div>

    <div id="items" class="d-flex flex-column gap-3">
      <%= render @items %>
    </div>
  </div>
</main>
<%# app/views/items/new.html.erb %>

<main class="container">
  <%= link_to "Back to items", items_path %>

  <%= turbo_frame_tag "modal" do %>
    <h1 class="h3 modal-header">
      New item
    </h1>

    <div class="modal-body">
      <%= render "items/form", item: @item %>
    </div>
  <% end %>
</main>

When clicking on the "New item" button on the Items#index page, the form is successfully inserted inside the modal, but the modal does not yet open.

Luckily, we can use Turbo's custom events to:

  • Open the modal when a Turbo Frame is inserted inside it.
  • Close the modal when a valid form is submitted.

According to the documentation:

  • The turbo:frame-load event fires when a <turbo-frame> element finishes loading. In our case, this means, when the form was successfully inserted inside our "modal" Turbo Frame.
  • The turbo:submit-end event fires after the form submission-initiated network request completes. In our case, that means when the form submission for our new item ends.

We will use those two events to create a Stimulus controller that will open and close the modal. Let's add the markup in our layout to bind those two events to the corresponding JavaScript behavior inside the modal Stimulus controller.

<%# app/views/layouts/application.html.erb %>

<body>
  <%= yield %>

  <div
    class="modal"
    tabindex="-1"
    data-controller="modal"
    data-action="turbo:frame-load->modal#open turbo:submit-end->modal#close"
  >
    <div class="modal-dialog">
      <div class="modal-content">
        <%= turbo_frame_tag "modal" %>
      </div>
    </div>
  </div>
</body>

As we can see in the previous markup, we want to open the modal when the turbo:frame-load event is fired and close the modal when the turbo:submit-end event is fired.

Let's now create the corresponding Stimulus controller:

// app/javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus"
import * as bootstrap from "bootstrap"

export default class extends Controller {
  connect() {
    this.modal = new bootstrap.Modal(this.element)
  }

  open() {
    if (!this.modal.isOpened) {
      this.modal.show()
    }
  }

  close(event) {
    if (event.detail.success) {
      this.modal.hide()
    }
  }
}

Let's break this code down together.

First, when the Stimulus controller is connected to the DOM, we instantiate a modal object thanks to the Bootstrap JavaScript library and store it inside this.modal. If you don't use Bootstrap, the code will be slightly different here, but the principle remains the same.

In the open action, we can open the modal when it is not already opened.

In the close action, we can check if the form submission was successfull with event.detail.success. If that is the case, we can close the modal.

As the Stimulus controller closes the modal automatically, it is not necessary to manually remove the content inside it. We can safely delete the line that removes the form in the Turbo Stream create.turbo_stream.erb view:

<%# app/views/items/create.turbo_stream.erb %>

<%= turbo_stream.prepend "items", @item %>

Our application should now work as expected if we test our modal in the browser!