DEV Community

toothlesspanda
toothlesspanda

Posted on

1 Modal to Rule them All: Rails x Turbo x Stimulus

When implementing modals, it’s common to create them individually in many different places. This can lead to repetitive code cluttering the DOM. One good approach would be to use a strategy similar to flash messages: have just one modal element and use the Hotwire ecosystem(Turbo + Stimulus) to dynamically and reactively change its content.

captionless image

Dependencies needed

I will be assuming you have everything bellow installed and working as intended.

Gems: rails +8 , turbo-rails +2.0 , stimulus-rails +1.3

Packages: @hotwired/stimulus +3.2 , @hotwired/turbo-rails +8.0 , bootstrap +5.3

Create a modal with dynamic content

Let's just create a simple modal, with a turbo_frame_tag wrapping your body content with a simple bootstrap close button

<!-- views/shared/_modal.html.erb -->
<div class="modal fade">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content d-inline rounded-4">
      <div class="modal-body p-4">
        <div class="position-absolute end-0 px-4">
          <button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <%= turbo_frame_tag "modal-body" do %>
        <% end %>
      </div>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

and render it in your application's layout

<!-- views/layouts/application.html.erb -->
<!-- ... -->
<body>
  <%= render "shared/modal" %>
  <%= yield %>
</body>
Enter fullscreen mode Exit fullscreen mode

Now, in routes, inside your controller you need to add a new route responsible for updating the modal content.

# routes.rb 
resources :modal_test, only: [ :index ] do
  collection do
    get :modal #new endpoint
  end
end
Enter fullscreen mode Exit fullscreen mode

In your controller create the matching method

# controllers/modal_test_controller.rb
class ModalTestController < ApplicationController
  # ...
  def modal
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's create the content for your modal's body

<!-- views/modal_test/_modal_body.html.erb -->
<div>
  Modal Content A
</div>
Enter fullscreen mode Exit fullscreen mode

Then we need a turbo stream file matching the method we just created in our controller, which will be responsible for updating the body content on the turbo frame we added in our _modal.html.erb.

<!-- views/modal_test/modal.turbo_stream.erb -->
<%= turbo_stream.update("modal-body", partial: "modal_test/modal_body") %>
Enter fullscreen mode Exit fullscreen mode

Now you just need to add the button to trigger the modal

<!-- views/modal_test/index.html.erb -->
<div class="container d-flex  flex-column  justify-content-center align-items-center mt-5" >
  <div class="d-flex flex-column justify-content-center align-items-center">
   <%= form_tag(modal_modal_test_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
       <%= button_tag "Modal A", type: :submit, class: "btn btn-primary px-4 py-2" %>
   <% end %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

To make the interaction happen we need to build a stimulus controller. Let's call it modal_controller.js to keep the semantics, and import the necessary classes from stimulusand bootstrap libraries

// app/javascript/controllers/modal_controller.js
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

export default class extends Controller {

}
Enter fullscreen mode Exit fullscreen mode

Don't forget to add it on the _app/javascript/controllers/index.js_ .

Now let's add our connect function and create our modal object that will be invoking this controller (which we haven't done yet) and add 2 listeners.

  • turbo:before-stream-render to listen to turbo changes before rendering it

Why **_turbo:before-stream-render_** ?_
We are triggering the modal and the turbo action at the same time, which can lead to race conditions. Without this, you will be opening the modal with empty content before the render happens. The goal here is to open the modal only when the render actually happens. We will be processing turbo stream actions, not navigations, so for instance_
turbo:*-frame-* in the documentation says: "Turbo Frames emit events during their navigation life cycle"_
Listening to the selected event is the only option we have for streams close to when they are triggered._

  • hidden.bs.modal to look at bootstrap closing modal event
// app/javascript/controllers/modal_controller.js
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';

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

        document.addEventListener('turbo:before-stream-render', /*some function A*/);
        this.element.addEventListener('hidden.bs.modal', /*some function B*/));
    }

    disconnect() {
        document.removeEventListener('turbo:before-stream-render', /*some function A*/));
        this.element.removeEventListener('hidden.bs.modal', /*some function B*/));
    }
}
Enter fullscreen mode Exit fullscreen mode

We need to identify the modal by adding a dynamic ID as a turbo value and initialise it. This will allow us to have multiple modals in the same page and also to execute this code restrictedly for those modals only.

This controller will be instantiated once for each distinct modal rendered on the page. So if you’re including multiple modals with different layouts or IDs, each will have its own instance of the controller. We’ll address this behaviour later in the article.

// app/javascript/controllers/modal_controller.js
export default class extends Controller {
    // new value to store the frameId aka the body id of the modal
    static values = {
        frameId: String,
    }; 

    connect() {
        this.modal = new Modal(this.element);
        // store the reference to the modal
        this.frame = this.hasFrameIdValue ? document.getElementById(this.frameIdValue) : null; 

        document.addEventListener('turbo:before-stream-render', /*some function A*/);
        this.element.addEventListener('hidden.bs.modal', /*some function B*/));
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

We can go back to our modal and make sure we call this controller and add the frameId value matching the frame tag from the body as well.

<!-- views/shared/_modal.html.erb -->
<div class="modal fade" id="modal" tabindex="-1" data-controller="modal" data-turbo-permanent data-modal-frame-id-value="modal-body">
 <!-- ... -->
</div>
Enter fullscreen mode Exit fullscreen mode

data-turbo-permanent: this allows the modal to be permanent throughout the pages and ignore turbo navigations — avoiding duplications.

Now we need to build the function that will look at stream changes and toggle the modal via Bootstrap API. And we can also create the function to reset the frame's content once the modal is closed.

// app/javascript/controllers/modal_controller.js
// update connect
document.addEventListener('turbo:before-stream-render', this.toggleModalOnStreamRender.bind(this));
this.element.addEventListener('hidden.bs.modal', this.resetContent.bind(this));

// update disconnect
 document.removeEventListener('turbo:before-stream-render', this.toggleModalOnStreamRender.bind(this));
this.element.removeEventListener('hidden.bs.modal', this.resetContent.bind(this));

// new function
toggleModalOnStreamRender = (event) => {
    // get the target name
    const renderedFrame = event.detail.newStream.target; 
    // check if it maches with the frameId of the modal
    if (renderedFrame !== this.frameIdValue) return;
    this.modal.show();
};

// new function
resetContent = (event) => {
    if (this.frame) this.frame.innerHTML = '';
};
Enter fullscreen mode Exit fullscreen mode

Close modal on submit:turbo action

Modals are typically used to perform an action — such as submitting a form — and are then closed automatically once the controller processes the request. If the form submission leads to a full page refresh, there’s usually no need to handle the modal closing manually. However, in many cases, you may be using Turbo Streams to update part of the page dynamically after the modal submits, in which case you’ll need to explicitly handle closing the modal in your response.

For this approach, we will make the submit action to trigger the turbo event we have been listening in the stimulus controller ( turbo:before-stream-render). Let's add a button inside the body content we want to render in the modal, which will call a new action from our controller.

# routes.rb 
resources :modal_test, only: [ :index ] do
  collection do
    get :modal
    post :modal_action
  end
end
Enter fullscreen mode Exit fullscreen mode
# controllers/modal_test_controller.rb
class ...
  # ...
  def modal_action
    @some_parameter = params[:some_parameter] 
  end
  # ...
end
Enter fullscreen mode Exit fullscreen mode
<!-- views/modal_test/_modal_body.html.erb -->
<div>
  Modal content A
  <%= button_to "Yes",
                modal_action_modal_test_index_url,
                method: :post,
                params: { some_parameter: "some_value_a" },
                class: "btn btn-primary px-4 py-2",
                form_class: "d-inline-block" %>
</div>
Enter fullscreen mode Exit fullscreen mode

Build action for modal submit

Now we will create an isolated turbo stream to trigger the closing behaviour. This segregation allow us to call this close action in any controller responses where we are managing modals.

This action will be responsible for appending a "meta" element which will trigger, again, turbo:before-stream-render event. In this case, we need to add a flag(data-close='true') — by adding a custom attribute to the main element inside of this html. By doing this we can intercept it in our javascript function and react on it to close the modal.

# views/shared/_close_modal.turbo_stream.erb
<%= turbo_stream.append("#{local_assigns[:modal_body_id]}", html: "<span data-close='true'></span>".html_safe) %>
Enter fullscreen mode Exit fullscreen mode

To make it work we need to adapt toggleModalOnStreamRender in our stimulus controller in order to retrieve the custom attribute data-close and whenever it's present we hide the modal.

toggleModalOnStreamRender = (event) => {
    const renderedFrame = event.detail.newStream
    if (renderedFrame.target; !== this.frameIdValue) return;

    // new line here to get the close-action signal
    const isCloseAction = renderedFrame.templateContent.querySelector('[data-close]')?.dataset?.close;

    // close modal if action is present
    if (isCloseAction) this.modal.hide();
    else this.modal.show();
};
Enter fullscreen mode Exit fullscreen mode

Lets add a frame in our index so we can see things changing after submitting the modal.

<!-- views/modal_test/index.html.erb -->
<div class="container d-flex  flex-column  justify-content-center align-items-center mt-5" >
  <div class="d-flex flex-column justify-content-center align-items-center">
   <%= form_tag(modal_modal_test_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
     <%= button_tag "Modal A", type: :submit, class: "btn btn-primary px-4 py-2" %>
   <% end %>
  </div>
  <!-- New frame-->
  <div>
    <h3> Action update: </h3>
    <%= turbo_frame_tag "action-change-modal"  do%>
    <% end %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Finally, our controller needs to return a Turbo Stream response from the modal_action method (triggered by the button inside the modal). This response should both update the target frame on the page and trigger the modal to close.

# controllers/model_test/modal_action.turbo_stream.erb
<%= turbo_stream.update("action-change-modal", html: "<span> #{@some_parameter}</span>".html_safe) %>
<%= render "shared/close_modal", { modal_body_id: "modal-body" } %>
Enter fullscreen mode Exit fullscreen mode

Multiple places calling the same modal

Let's say that now, you have another use case where a modal is needed. Instead implementing it from scratch, you just need to reuse the same approach, like this:

  • Create a new partial with the expected content of this new use case
<!-- views/modal_test/_modal_body_b.html.erb -->
<div>
  Modal content B
  <%= button_to "Yes",
                modal_action_modal_test_index_url,
                method: :post,
                params: { some_parameter: "some_value_b" },
                class: "btn btn-primary px-4 py-2",
                form_class: "d-inline-block" %>
</div>
Enter fullscreen mode Exit fullscreen mode
  • Adapt controller turbo responses or add a new controller method

On the controller side, you can either use the same endpoint if you are on the same page or adding a new route to be called by the turbo-action.

Following the first approach, you can send a value within the buttons identifying which desirable content our controller needs to call to render inside the modal.

<%= form_tag(modal_modal_test_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
  <%= button_tag "Modal A", name: "modal_type", type: :submit, value: "modal_a", class: "btn btn-primary px-4 py-2" %>
  <%= button_tag "Modal B", name: "modal_type", type: :submit, value: "modal_b", class: "btn btn-warning px-4 py-2" %>
<% end %>

Enter fullscreen mode Exit fullscreen mode
# controllers/model_test_controller.rb
class ModalTestController
  # ...
  def modal
    @modal_type = params[:modal_type]
  end
  #...
end
Enter fullscreen mode Exit fullscreen mode
# views/modal_test/modal.turbo_stream.erb
<% modal_type_partial = @modal_type == "modal_a" ? "modal_body_a" : "modal_body_b"  %>
<%= turbo_stream.update("modal-body", partial: "modal_test/#{modal_type_partial}") %>
Enter fullscreen mode Exit fullscreen mode

…or by if inside a new controller (because it might be in a different page)…

<%= form_tag(modal_modal_test_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
   <%= button_tag "Modal A", type: :submit, class: "btn btn-primary px-4 py-2" %>
<% end %>

<%= form_tag(modal_different_controller_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
   # hidden_tag ...
   <%= button_tag "Modal B", type: :submit, class: "btn btn-warning px-4 py-2" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode
# views/modal_test/modal.turbo_stream.erb
<%= turbo_stream.update("modal-body", partial: "modal_test/modal_body_a") %>

# views/different_controller/modal.turbo_stream.erb
<%= turbo_stream.update("modal-body", partial: "modal_test/modal_body_b") %>
Enter fullscreen mode Exit fullscreen mode
# controllers/model_test_controller.rb
class ModalTest
  # ...
  def modal
  end
  #...
end

# controllers/different_controller.rb
class DifferentController
  # ...
  def modal
  end
  #...
end
Enter fullscreen mode Exit fullscreen mode

Add a new modal with a different layout

Let's say now your designer decides to create a different layout that you can't manipulate in your original one. We built the foundation for our modals, so now we just need to recreate a few steps.

  1. Create the baseline of your modal, with a different ID in our frameId matching the same ID as the body
<div class="modal fade" id="different-modal" tabindex="-1" data-controller="modal" data-turbo-permanent data-modal-frame-id-value="modal-different-layout-body">
  <div class="modal-dialog modal-dialog-centered">
    <div class="modal-content d-inline rounded-5">
      <div class="modal-header bg-warning rounded-top-5">
        Title
        <div class="position-absolute end-0 px-4">
          <button class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
      </div>
      <div class="modal-body p-4 d-flex flex-column align-items-center">
        This is a modal with a different layout
        <%= turbo_frame_tag "modal-different-layout-body" do %>
        <% end %>
      </div>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
  1. Render it on the application layout
<body>
  <%= render "shared/modal" %>
  <%= render "shared/modal_different_layout" %>
  <%= yield %>
</body>
Enter fullscreen mode Exit fullscreen mode
  1. Create the body content (in this example we are pointing to the original controller but it could be anything)
<!-- views/modal_test/modal_body_different_layout.html.erb -->
<div>
  Different Layout
  <%= button_to "Yes",
                modal_action_modal_test_index_url,
                method: :post,
                params: { some_parameter: "some_value_different" },
                class: "btn btn-primary px-4 py-2",
                form_class: "d-inline-block" %>
</div>
Enter fullscreen mode Exit fullscreen mode
  1. Add the button to open the new modal
<div class="container d-flex  flex-column  justify-content-center align-items-center mt-5" >
  <div class="d-flex flex-column justify-content-center align-items-center">
    <%= form_tag(modal_modal_test_index_path, method: :get, class: "d-inline-block", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
      <%= button_tag "Modal A", name: "modal_type", type: :submit, value: "modal_a", class: "btn btn-primary px-4 py-2" %>
      <%= button_tag "Modal B", name: "modal_type", type: :submit, value: "modal_b", class: "btn btn-warning px-4 py-2" %>
    <% end %>

     <!-- this step -->
    <%= form_tag(modal_different_layout_modal_test_index_url, method: :get, class: "d-inline-block mt-3", data: { turbo_frame: "modal-body", turbo_stream: true }) do %>
      <%= button_tag "Modal Different Layout", type: :submit, class: "btn btn-danger px-4 py-2" %>
    <% end %>
  </div>

  <div>
    <h3> Action update: </h3>
    <%= turbo_frame_tag "action-change-modal"  do%>
    <% end %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
  1. Create an action on the controller side to render the content of that modal
# controllers/model_test_controller.rb
class ModalTestC
  # ...
  def modal_different_layout
  end
  #...
end
Enter fullscreen mode Exit fullscreen mode
# controllers/modal_different_layout.turbo_stream.rb
<%= turbo_stream.update("modal-different-layout-body", partial: "modal_test/modal_body_different_layout") %>
Enter fullscreen mode Exit fullscreen mode
  1. If you are using turbo actions inside the modal, you just need to call the close_modal partial done previously on this article.
<%= turbo_stream.update("action-change-modal", html: "<span> #{@some_parameter}</span>".html_safe) %>
<%= render "shared/close_modal", { modal_body_id: "modal-body" } %>
<%= render "shared/close_modal", { modal_body_id: "modal-different-layout-body" } %>
Enter fullscreen mode Exit fullscreen mode

Everything together

captionless image

Sequence diagram

Diagram illustrating the modal approach (using Mermaid language)

This is just a different way to handle modals, using turbo-streams. There's probably plenty of approaches you can apply accordingly with what suits you better on your use case.

Feel free to share on the comments, what strategies do you apply to handle modals?

Happy coding!

Top comments (0)