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.
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>
and render it in your application's layout
<!-- views/layouts/application.html.erb -->
<!-- ... -->
<body>
<%= render "shared/modal" %>
<%= yield %>
</body>
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
In your controller create the matching method
# controllers/modal_test_controller.rb
class ModalTestController < ApplicationController
# ...
def modal
end
end
Let's create the content for your modal's body
<!-- views/modal_test/_modal_body.html.erb -->
<div>
Modal Content A
</div>
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") %>
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>
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 stimulus
and bootstrap
libraries
// app/javascript/controllers/modal_controller.js
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
export default class extends Controller {
}
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*/));
}
}
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*/));
}
// ...
}
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>
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 = '';
};
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
# controllers/modal_test_controller.rb
class ...
# ...
def modal_action
@some_parameter = params[:some_parameter]
end
# ...
end
<!-- 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>
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) %>
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();
};
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>
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" } %>
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>
- 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 %>
# controllers/model_test_controller.rb
class ModalTestController
# ...
def modal
@modal_type = params[:modal_type]
end
#...
end
# 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}") %>
…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 %>
# 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") %>
# controllers/model_test_controller.rb
class ModalTest
# ...
def modal
end
#...
end
# controllers/different_controller.rb
class DifferentController
# ...
def modal
end
#...
end
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.
- 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>
- Render it on the application layout
<body>
<%= render "shared/modal" %>
<%= render "shared/modal_different_layout" %>
<%= yield %>
</body>
- 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>
- 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>
- 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
# controllers/modal_different_layout.turbo_stream.rb
<%= turbo_stream.update("modal-different-layout-body", partial: "modal_test/modal_body_different_layout") %>
- 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" } %>
Everything together
Sequence diagram
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)