Joseph Izaguirre

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 toasting 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.

Succesfull Submission

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!

  1. 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. 

ABOUT ME

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