rails
Build a modal form with Rails, Turbo, and the dialog element
I’ll explain the differences and benefits of Turbo Streams vs Turbo Frames in this HTML-first, test-driven tutorial. Only one line of JavaScript needed!
Let’s test-drive
I find that writing a simple test up front is a useful way to think through the user experience and document a to-do list for the implementation. Let’s write a quick system test to describe the behavior we want to see.
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
test "create valid user via modal" do
visit "users"
click_link "New user"
within "dialog" do # (1) Modal opens
fill_in "Name", with: "Matt"
click_button "Create User"
assert_text "Email can't be blank" # (2) Still in the modal
fill_in "Email", with: "[email protected]"
click_button "Create User"
end
assert_no_selector "dialog" # (3) Modal closes on success
assert_text "User was successfully created." # Flash message
end
end
The key takeaways here are:
- Clicking “New user” opens a modal.
- Submitting an invalid form re-renders it within the modal.
- On success, the modal closes.
With the test in place, let’s start building!
Scaffold the user pages
Turbo is great because it allows us to enhance the server-rendered behavior that Rails already excels at. We can build our feature in the traditional way, using familiar Rails techniques: model, route, controller, and view. Once the basics are in place, we can layer Turbo on top.
I’m using a blank Rails 7.2 app as my starting point, which comes with Turbo preinstalled:
rails new turbo_modal --skip-jbuilder
jbuilder adds a bunch of JSON routes to the controller scaffold that are a distraction for the purposes of this tutorial, so I’ve omitted it.
First, let’s scaffold out a user:
bin/rails generate scaffold user name:string email:string
bin/rails db:migrate
Then add some model validations to make things more interesting:
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
validates :name, :email, presence: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true }
validates :email, uniqueness: true
end
And just like that, we have a working app! 😎
Now let’s make this interaction shine with a Turbo-driven modal.
Wrap it in a <dialog>
Putting the user form inside a modal is surprisingly simple, because browsers already provide a built-in <dialog> element coupled with a showModal function. Let’s wrap the users/new.html.erb view template in a dialog:
<% content_for :title, "New user" %>
<dialog id="new_user_dialog">
<h1>New user</h1>
<%= render "form", user: @user %>
</dialog>
<script type="text/javascript">
new_user_dialog.showModal();
</script>
<dialog> by default, until showModal is called. This is the only line of JavaScript we’ll need for this tutorial.
Done! Except… the rest of the page has disappeared.
That’s because a full-page navigation happens when “New user” is clicked, so the /users/new page completely replaces the previous one.
We need a way to add the /users/new content to the existing page without replacing it. That’s where Turbo Frames comes into play.
Target a Turbo frame
To place our modal on top of the users page instead of replacing it, we need a frame to put it in. Let’s add one to users/index.html.erb and tell link_to to use it:
<%= link_to "New user", new_user_path, data: { turbo_frame: "modal" } %>
<%= turbo_frame_tag "modal" %>
Turbo’s JavaScript will now intercept “New user” clicks and load the resulting /users/new content into the Turbo frame named “modal”. This only works if /users/new is also wrapped in a Turbo frame with the same ID. Otherwise we’ll get the dreaded “content missing” error in the browser console.
Error: The response (200) did not contain the expected
<turbo-frame id="modal">and will be ignored.
Let’s fix that by updating users/new.html.erb so that the contents are wrapped in the expected <turbo-frame>:
<% content_for :title, "New user" %>
<%= turbo_frame_tag "modal" do %>
<dialog id="new_user_dialog" open>
<h1>New user</h1>
<%= render "form", user: @user %>
</dialog>
<script type="text/javascript">
new_user_dialog.showModal();
</script>
<% end %>
We’re getting close! The modal appears on top of the users page, but submitting the form doesn’t quite work yet.
Close the modal on submit
By default, when inside a Turbo frame, clicking a link or submitting a form will render the resulting content inside the same frame. That’s good general behavior, but it isn’t what we want for our modal frame.
When the form is successfully submitted, we want the modal frame to go away and control to return to the top-level page. To put it another way: we want the form submission to break out of the frame. This is actually pretty straightforward if we use the special _top value for the Turbo frame target.
Let’s update the form element in users/_form.html.erb to tell Turbo to break out of the frame when the form is submitted:
<%= form_with(model: user, data: { turbo_frame: "_top" }) do |form| %>
Great! Our “happy path” is working!
Unfortunately turbo_frame: "_top" is a very blunt instrument. Now all form submissions will break out of the modal frame, including unsuccessful ones with validation errors.
We need a way to target _top for successful submissions and target modal when there are validation errors, but that’s beyond the capabilities of Turbo navigation. To solve this we have to bring in a new set of tools: Turbo Streams.
Conditionally re-render with Turbo Streams
When I first encountered Turbo, I was confused by Turbo Streams and how they differ from Turbo Frames. Here’s how I now understand the concepts:
Turbo Frames are declarative: we add data attributes to our HTML tags, then Turbo takes over and magically modifies the navigation behavior of our app. Turbo Streams, on the other hand, are imperative: we issue explicit actions in Ruby code using helper methods provided by the turbo-rails gem, which Turbo then translates into JavaScript-driven updates to the browser DOM.
In the case of solving the happy-path-vs-validation-errors scenario, the declarative nature of Turbo Frames is too basic, but the imperative approach of Turbo Streams is a good fit: if the form submission has validation errors, then render the response differently.
Apply a DOM ID
First, to conditionally re-render part of the page, we’ll need to target a specific DOM ID. Let’s add id=user_form to the <form> element:
<%= form_with(model: user, id: "user_form", data: { turbo_frame: "_top" }) do |form| %>
Respond with a Turbo Stream
If there are validation errors, we want user_form to be re-rendered in place. We can do that using the turbo_stream.replace action in users_controller.rb, like this:
if @user.save
redirect_to @user, notice: "User was successfully created."
else
respond_to do |format|
# This is the default scaffold behavior to support non-Turbo requests
format.html { render :new, status: :unprocessable_entity }
# For a Turbo-driven request, re-render the form
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"user_form",
partial: "users/form",
locals: { user: @user }
)
end
end
end
Are we done?
To make sure, let’s check the system test we wrote at the outset:
$ bin/rails test test/system/users_test.rb --verbose
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --verbose --seed 44431
# Running:
UsersTest#test_create_valid_user_via_modal = 2.13 s = .
Finished in 2.137665s, 0.4678 runs/s, 1.4034 assertions/s.
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips
All assertions succeed and the test passed. We did it!
What’s next?
When I use this technique in real apps, I extract the showModal one-liner into a Stimulus controller in charge of handling modal-related JS behavior. This Stimulus controller also implements an action for a close button, to make the modal more accessible. For brevity, I’ve omitted this detail (and other things like database constraints and unit tests) from this tutorial.
Progressive enhancement
Also, where possible, I strongly believe Turbo/Stimulus behavior should progressively enhance the underlying server-rendered experience. As it stands, our modal doesn’t work without JavaScript, which is not ideal.
One solution is to extract the modal wrapper into a separate layout, which we can use conditionally based on whether Turbo is being used or not:
<%= turbo_frame_tag "modal" do %>
<dialog id="dialog">
<%= yield %>
</dialog>
<script type="text/javascript">
dialog.showModal();
</script>
<% end %>
The contents of the modal are injected by
yield (i.e. the contents of our users/new.html.erb view template).
With the modal-specific part extracted, our new.html.erb template reverts to the plain scaffold code:
<% content_for :title, "New user" %>
<h1>New user</h1>
<%= render "form", user: @user %>
In the controller, we can then conditionally apply the modal layout or application layout based on whether the request is targeting the modal Turbo Frame:1
def new
@user = User.new
layout = turbo_frame_request_id == "modal" ? "modal" : "application"
render "new", layout:
end
This works! Our system test still passes, and now with JavaScript disabled, users will get the modal-less scaffold experience.
CSS bonus round
Finally, let’s address what’s painfully obvious: the modal we made is pretty ugly! Luckily, since it’s built with standard HTML we can easily apply whatever styling solution we want. Here is some vanilla CSS that gets the job done:
* {
box-sizing: border-box;
}
html {
font-family: system-ui;
}
input {
font-size: inherit;
}
dialog {
border: none;
border-radius: 12px;
box-shadow: 0 4px 8px rgb(0 0 0 / 25%);
padding: 28px;
width: min(85vw, 48ch);
h1 {
font-size: 1.5em;
font-weight: 600;
margin-block: 0 40px;
}
label {
margin-block: 20px 8px;
}
input[type="text"],
input[type="submit"] {
border-radius: 6px;
padding: 12px;
width: 100%;
}
input[type="text"] {
border: 1px solid lightgray;
}
input[type="submit"] {
appearance: none;
background: royalblue;
border: none;
color: white;
cursor: pointer;
font-weight: 600;
margin-top: 32px;
}
}
And the result:
Conclusion
I hope this walk-through illustrated how to practically apply Turbo Frames and Turbo Streams using a gradual, layered approach on top of standard Rails scaffolding. There are many ways to accomplish modals in Rails, but this approach feels the most “Rails-y” to me. Thanks for reading!
-
Thank you Radan Skorić for introducing me to the
turbo-frameheader technique. ↩