Building a modal form with Turbo Stream GET requests and custom stream actions24 Sep 2022
Turbo 7.2 brought major changes to what you can accomplish with Turbo Streams. To demonstrate those changes, today we are going to build a Turbo Stream-powered modal form without a single Turbo Frame. We will also create our own custom Turbo Stream action that emits a custom event on the browser.
The key technique we will use in this article is an update to the standard method of using Turbo to open and populate modals. Prior to Turbo 7.2, Turbo would not allow GET requests to respond directly with Turbo Streams. Smart folks devised a workaround to this limitation which involved using empty Turbo Frames to sneak in Turbo Stream actions. That workaround still works fine, but it is no longer necessary.
As part of our modal implementation, we will also have a chance to build a custom Turbo Stream action. Custom actions are an entirely new feature added in Turbo 7.2, and we will cover enough to get you started with this new tool in Turbo.
When we are finished our application work like this:
To follow along with this article, you will need to be comfortable with Ruby on Rails and you should have some experience using Stimulus. We will move quickly through the Turbo content, so previous exposure to using Turbo Streams will be helpful but is not required. As usual, you can review the complete code for this tutorial on Github.
Let’s get started!
We will start with a new Rails 7 application with esbuild and Tailwind along with Turbo 7.2 and Stimulus. From your terminal:
Note that neither Tailwind or esbuild are hard requirements, just personal preferences. The Turbo Stream and Stimulus techniques will work fine here with import maps, Vite, and any CSS framework you like.
For this exercise, users will be creating “cards”, which we will scaffold up using the Rails generator. From your terminal again:
Start up the server with
bin/dev from your terminal.
Open the code up in your favorite editor and head to
app/views/cards/index.html.erb for some non-essential layout updates:
More small layout updates in
With cards scaffolded up and the views cleaned up a bit, we are ready to start building the modal.
Adding new cards in a modal
To open and close the card form modal, we are going to use a Stimulus controller.
Generate that controller from your terminal:
Fill that new Stimulus controller in at
As the comment notes, this code is a stripped-down version of the production-ready modal provided in Chris Oliver’s tailwindcss-stimulus-components library. I modified it to remove the extras that we don’t need for our project and to save us a few chunks of copy/pasted code that we would need if we were using the full library.
open opens the modal and
close, shockingly, closes the modal by applying the right Tailwind classes and toggling in and out a background overlay.
To use this new Stimulus controller, we need to connect it to the DOM. Head over to
Here, we connected the
modal controller to the
body. Inside of the
body, we render a modal partial that does not exist yet.
Create that partial next, from your terminal:
Fill in the modal partial:
The key pieces in this partial are:
modal-targetdata attribute on the container div. The Stimulus controller uses this attribute to know which element to apply classes to when the modal is opened and closed.
modal-titleheader and the
modal-bodydiv, both of which are empty when the modal partial is rendered.
modal-body elements will be filled in with content from the server when we open the modal using the magic of Turbo Streams.
app/views/cards/index.html.erb to adjust the New Card link to open a modal when clicked:
Note the addition here of two data attributes on the “New Card”
data-action attribute tells Stimulus to fire the
modal#open method in the modal controller, making the modal visible to the user. The other attribute,
data-turbo-stream tells Turbo to send the request to the server as a Turbo Stream request, instead of a standard HTML request.
data-turbo-streams is new in Turbo 7.2. This addition opens up the ability to respond to GET requests with Turbo Stream actions directly, instead of trojan horsing actions inside of empty Turbo Frames. This change allows us to write a little less code which is nice. Even better, it makes our intentions as developers more clear. The empty Turbo Frame work around was a confusing piece of indirection that we no longer need to rely on.
data-turbo-stream attribute added, Turbo will expect a Turbo Stream response from the server when the new card link is processed. To make this work, we need to tell Rails what we want to render in response to a Turbo Stream request to
From your terminal:
Fill in the new view:
In this case, that means we will send over content for the empty header with an id of
modal-title and the empty
modal-body div. Turbo makes those updates, and our modal magically gets the updated, server-rendered content.
Update the cards form partial at
app/views/cards/_form.html.erb before checking to see if our modal works:
Refresh the cards index page at http://localhost:3000/cards, click on the “New Card” link and, if all has gone well so far, a modal should open with the new card header text and the card form populated.
At this point submitting the card form will create a new card in the database but, after saving the card you will be redirected to the card show page. Redirecting defeats the purpose of opening the card form in the modal. Let’s fix this issue next.
First, create another Turbo Stream view. From your terminal:
And fill that view in:
Easy enough. We use a Turbo Stream prepend action to insert the newly created card in the list.
Head over to
app/controllers/cards_controller.rb and update the
The addition of
format.turbo_stream when a card saves ensures that inbound Turbo Stream requests render the
create.turbo_stream.erb view that we added.
By default, Turbo assumes all form submissions are Turbo Stream requests, so we don’t need to make any changes to our form for this to work, just having a Turbo Stream view is enough.
Head back to the cards index, open the modal and save a card. You’ll see the newly created card added to the top of the list of cards but the modal stays open.
Users will expect the modal to close when they submit the form, so let’s address that next.
Creating a custom Turbo Stream action
We are going to use a custom Turbo Stream action to dispatch an event when the card form is submitted successfully. This event will be generated on the server, sent over the wire to the frontend, processed by Turbo, and picked up by a waiting Stimulus controller to close the modal.
It’ll be pretty fancy. It will also be completely unnecessary. Turbo emits an event after a form submission that would work just fine to close our modal. Building a custom Turbo Stream action to submit an event is purely for demonstration purposes.
In the real world, if you need to close a modal after a Turbo Stream submission, the built-in event is just fine. It just isn’t as fun as rolling our own custom Turbo Stream action with the new tools provided for us in Turbo 7.2.
From your terminal, create a new directory and file where we will define our custom event:
Here, we are importing StreamActions from Turbo and extending it to add a new
dispatch_event action to
dispatch_event takes a
name attribute that we then use to define and dispatch an Event on the
While dispatching the event on the window works fine for this tutorial, in the real world, you can scope the event to a particular element instead of blasting it out on the window. I’ve noted a simple approach to that problem as a comment.
You might also want to define a new CustomEvent instead of an
Event. Using a
CustomEvent allows you to include additional data in the event payload. Again, we don’t need that extra data in this tutorial, but the possibility is there as you explore.
Head over to
Next we need to define a matching Turbo Stream helper tag on the server. From your terminal:
And fill in the new
Nothing fancy. We add a new
dispatch_event helper and add it to the list of available Turbo Stream action tags.
turbo-rails. This work was instrumental in getting full-featured support for custom Turbo Stream actions into our hands.
To use this new helper tag, update
With this change, the Turbo Stream payload will now include both a prepend action and our custom
dispatch_event action, which will emit an event on the client side named “modalClose”.
The last step to make this work is to fire the
close method in the modal Stimulus controller when the
modalClose event is received.
Note the new
data-action attribute on the body listening for the
modalClose custom event.
Head back to the cards index page, open the modal, create a new card, and see that the new card is prepended to the list and the modal closes. Incredible stuff.
Let’s look closer at what is happening here by looking at the payload that the server sends over the wire when it renders
That payload will look something like this:
The payload contains
turbo-stream custom elements with an action set. Each element wraps a
template tag, although the template for our
dispatch_event action is empty since that action does not need to render any HTML.
dispatch_event action in the payload.
This technique is incredibly powerful and opens the door for Turbo users to begin to experiment with more complex DOM manipulation in Turbo Stream actions. Our example dispatches an event but we could just as easily play a sound, morph in new content with morphdom, manipulate data attributes, or add or remove CSS classes by defining new custom Turbo Stream actions.
As you experiment with your own custom actions, you’ll find the Turbo source helpful for understanding how the existing Turbo Stream actions are implemented, and the turbo-rails source helpful for learning how to build new action helpers.
Let’s finish up this article by handling invalid form submissions with Turbo Streams.
First, update the
create method in
Here, we updated the unhappy path to render an inline
turbo_stream.replace action. When the card isn’t saved, we re-render the form and replace it inline, leaving the modal open with the errors displayed to the user.
Before we can test this, we need to add validation to the card model. Update
app/models/card.rb like this:
Open the card modal and submit the form with a blank name input. See that the modal stays open with the errors rendered on the form. Beautiful.
Great work making it to the end of this tutorial — I hope it has helped to see the changes in Turbo 7.2 in action, and I hope it gets the wheels turning on what you can do with custom Turbo Stream actions.
Today we built a modal form with Rails, Turbo, and Stimulus, using a Turbo Stream GET request and a custom Turbo Stream action, both of which are new tools unlocked by the release of Turbo 7.2.
Our implementation of a modal has some room to grow. If you want to stretch these muscles a bit, try adding functionality to clear the modal content each time it closes. Without clearing the content on close, the user will see a brief flash of old content each time the modal is opened. To implement this, you might consider emptying the
modal-body elements each time
close is called in the Stimulus controller. Adding the ability to edit existing cards in the same modal would also be a useful exercise.
If the world of highly targeted DOM manipulation initiated from the server is new to you, you might find inspiration in CableReady. CableReady provides 30+ operations out of the box, everything from simple HTML replacement to adding CSS classes, logging to the console, and dispatching events.
Custom Turbo Stream actions are a powerful tool, but if you find you need more, CableReady is the place to look.
That’s all for today — as always, thanks for reading!