Toast alerts with Turbo, Stimulus and Shoelace in Rails
Rails comes built-in with a flash alert system to pass along messages to users. The typical method involves displaying a block of text above the main content of the website. While this works, it’s not very interactive and can appear dated. And in Turbo Native apps, this breaks down the illusion of a Native app, tipping off your user that they’re looking at a web view.
This tutorial will show how to continue to use the built-in flash alert system in Rails, but Turbo-charge it with Shoelace Alert Components and Stimulus, while making it play nice with Turbo. This can get you started very quickly on a Toast alert system, or quickly upgrade an older Rails app that’s already using flash alerts with a minimal amount of code. 1
Let’s create a new Rails project with Esbuild to bundle our Javascript and PostCSS to process our CSS. (For this tutorial, I’m using Rails 7.0.4)
rails new alert-demo -j esbuild -c postcss
Now we have to setup Shoelace in our project. This is a little more involved then might initially appear, but thankfully Jared White has done a great job of breaking it down here.
Once Shoelace has been setup, let’s import the alert component and the icon component that we’ll use in the alerts:
/* app/javascript/application.js */
/* We'll be using the icon in the alerts */
import '@shoelace-style/shoelace/dist/components/alert/alert.js'
import '@shoelace-style/shoelace/dist/components/icon/icon.js'
Shoelace is finally setup and ready to go. Let’s create a post scaffold to use in our tutorial:
rails generate scaffold post content:text
rails db:migrate
Finally, head to http://localhost:3000/posts
to see all of the posts. This should be empty right now. Try creating a new post, and an alert notice should appear in green at the top of the page. It looks very basic, so let’s use Shoelace to make it better!
First off, let’s quickly refactor the alert system as it was generated by the scaffold. Let’s delete the rendered notice at the top of both app/views/posts/show.html.erb
and app/views/posts/index.html.erb
<!-- Delete this line! -->
<p style="color: green"><%= notice %></p>
Let’s create a flash partial and render it in our layout:
<!-- app/views/layouts/application.html.erb -->
...
<body>
<%= render "shared/flash" if flash.any? %>
<%= yield %>
</body>
...
<!-- app/views/shared/_flash.html.erb -->
<div data-controller="alert" data-turbo-cache="false">
<% flash.each do |message_type, message| %>
<sl-alert data-alert-target="alert" open closable>
<sl-icon slot="icon" name="info-circle"> </sl-icon>
<%= message %>
</sl-alert>
<% end %>
</div>
That’s a lot to unpack! Let me break it down.
The data-controller="alert"
part is for a future Stimulus controller that we’ll be creating. The data-turbo-cache="false"
is so that Turbo doesn’t cache this part of the page. This way, if the user navigates backwards, they won’t see the alert pop up again.
Next up, I’m iterating through each message in the flash to create an <sl-alert>
. This is optional, but nice if you want to be able to send multiple alerts to the user on a single trip to the server. Notice also that we have access to the message_type
in the block. This is very useful for creating variants of alerts (info, warning, danger, etc). I won’t go into that here to keep the code simple, but mapping message types to alert variants is a good idea.
Next we have the sl-alert
with data-alert-target="alert"
. The Stimulus controller will be using this to keep track of each alert as it appears on the page. The open
is so that the sl-alert appears on the page (we’ll get rid of this once we look at toasting) and the closable
is so that the user can close the alert.
I added an optional sl-icon
to provide a nice icon, and then rendered the message.
Try creating a few posts, or deleting a few posts. The alert should appear on the page. But it’s not a toast alert just yet, just a fancy version of the traditional Rails flash alert. Let’s use Stimulus to bring it to life!
First, remove the open
attribute from the sl-alert
so that the alert appears hidden on the page. We’ll be toast
ing them, so we don’t want them to actually appear in our markup.
<!-- app/views/shared/_flash.html.erb -->
...
<sl-alert data-alert-target="alert" closable>
...
Then create the stimulus controller:
rails generate stimulus alert
According to the Shoelace documentation, we need to call the toast()
method when the alert appears on the page. Our alert controller should look like the following:
//app/javascript/controllers/alert_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="alert"
export default class extends Controller {
static targets = [ "alert" ]
alertTargetConnected(alert){
alert.toast();
}
alertTargetDisconnected(alert){
alert.hide();
}
}
All this is doing is calling the toast()
method when the alert controller detects that the alert
target has been connected to the page, and calling the hide()
method when the alert target is removed from the page (like when the user navigates to a different page).
That’s mostly it. There is a slight bug, however. Try creating a post, then deleting that post from the same page. The first alert appears! But the second doesn’t pop up. This is where we have to make Turbo play nice with Shoelace’s toast alert system. When an alert is toasted, Shoelace creates a div
with a class of sl-toast-stack
. Shoelace then manages this div internally, waiting until all of the alerts are gone before removing the stack.
It looks like we have to manually remove the toast stack before the page is rendered. Turbo provides a before-submit
event we can use. Add this to application.js
and you should be good to go:
// app/javascript/application.js
document.addEventListener("turbo:submit-start", function() {
document.body.querySelector("div.sl-toast-stack")?.remove();
});
This just removes the toast stack on the start of every form submission, just in case there’s a stack already on the page.
Messing with the internal implementation of Shoelace’s toast alert system feels like a code smell to me. If you have a better idea as to how to workaround this Turbo-Shoelace bug, please message me on Twitter or shoot me an email. I’ll be happy to give you credit!
That’s all! You now have a slick toast alert system that uses the tried-and-true Rails flash system.
Bonus: Toast alerts in a Turbo Native app
One of the common form submission workflows in a Turbo Native app is to present the new
page in a modal with a seperate Session
, which passes the response HTML to the original Session
. This can be seen in the demo application. This is the equivalent of opening two separate windows in a browser, and can wreak havoc on Shoelace’s internal handling of the toast alerts. A workaround is to hide all alerts on each Turbo navigation, ensuring that there’s no Toast stack present on any page when receiving the response HTML:
// app/javascript/application.js
document.addEventListener("turbo:before-visit", function() {
let slAlerts = document.querySelectorAll("sl-alert");
slAlerts.forEach( (alert) => {
alert.hide();
});
});
Now your toast alert system works in both web browsers and Turbo Native apps!
-
For an alternative implementation of Toast notifications using the brand-new Custom Stream Actions in Turbo 7.2, check out Marco Roth’s excellent tutorial. ↩
I'm Joseph Izaguirre, a web dev focusing on Ruby, Rails, Hotwire, and Turbo Native. If you'd like to get in contact, reach out to me on Twitter at @izaguirrejoe_ or email me at izaguirrejoe@hey.com