DEV Community

Marco Roth
Marco Roth

Posted on • Originally published at marcoroth.dev

Turbo 7.2: A guide to Custom Turbo Stream Actions

The new Turbo 7.2 release has a bunch of new exiting features. One of the features is the ability to create custom Turbo Stream actions, which is what we are going to focus in this blog post.

Turbo ships 7 Stream Actions by default for manipulating the DOM on the client-side, namely: after, append, before, prepend, remove, replace and update.

These are enough to get a lot of functionality done, but there are still some use-cases where it's not enough or where it would be much more elegant to write a small JavaScript function which handles a certain thing or bit of application-specific business-logic.

This is where custom stream actions come into play. They allow you to write JavaScript functions which get invoked everytime a <turbo-stream> element with a matching action attribute gets connected to the DOM.

To accomplish this Turbo exports a StreamActions module on which you can define your custom actions.

Getting started

Before we start we want to make sure that we are actually running Turbo 7.2. Double check your package.json or config/importmap.rb if you are runnig @hotwired/turbo or @hotwired/turbo-rails on 7.2.0 or higher, and the turbo-rails gem on 1.3.0 or higher.

Implementing our first custom action

Let's start by implementing a simple custom console_log action. We assume that the server sends the following HTML:

<turbo-stream action="console_log" message="Hello World"></turbo-stream>
Enter fullscreen mode Exit fullscreen mode

On the JavaScript side we can define a custom action like this:

// app/javascript/application.js

import { StreamActions } from "@hotwired/turbo"

StreamActions.console_log = function() {
  const message = this.getAttribute("message")
  console.log(message)
}
Enter fullscreen mode Exit fullscreen mode

As soon as the <turbo-stream> element from above gets connected to the DOM it will call our JavaScript function.

Important to note is that you have to use the function() { ... } syntax to define your custom action. Arrow-functions won't work since they bind the scope of this to the outer scope where the arrow-function was defined from, which is not what we want here.

In custom actions this refers to an instance of the StreamElement class, which is the actual instance of the <turbo-stream> element that gets connected to the DOM.

Of course this is rather a simple example, let's look at something more advanced.

Implementing more complex actions

A few common use-cases come to mind where custom actions could be a good fit:

  • using HTML-diffing libraries like morphdom to efficiently update elements on the page
  • showing toast alerts
  • showing/hiding modals
  • updating dropdown contents
  • returning typeahead results
  • playing a sound
  • and so on...

You can basically wrap every JavaScript snippet or any npm package you could think of in an action.

In the next example we are going to implement a toast alert action using the toastify-js npm package.

Let's start by defining our API on the Ruby side of things. First off we want to create a module where our custom actions live. We can let Rails generate a helper for us using the generate command:

rails generate helper TurboStreamActions
Enter fullscreen mode Exit fullscreen mode

This generates us the following file:

# app/helpers/turbo_stream_actions_helper.rb

module TurboStreamActionsHelper
end
Enter fullscreen mode Exit fullscreen mode

Let's define our toast() method, which takes the text we want to show in the alert as an argument:

module TurboStreamActionsHelper
  def toast(text)
  end
end
Enter fullscreen mode Exit fullscreen mode

We want the toast helper to output a <turbo-stream> element which looks like this:

<turbo-stream action="toast" text="Hello world from Toastify!"></turbo-stream>
Enter fullscreen mode Exit fullscreen mode

Turbo Rails provides a turbo_stream_action_tag helper we can use to generate such a tag. We can pass over the text attribute as a keyword argument for it to get rendered as an attribute on the <turbo-stream> element:

module TurboStreamActionsHelper
  def toast(text)
    turbo_stream_action_tag :toast, text: text
  end
end
Enter fullscreen mode Exit fullscreen mode

And that's it. Now we just need to tell Turbo that we defined this custom action in our TurboStreamActionsHelper module by prepend-ing it to Turbo::Streams::TagBuilder module at the end our helper file:

module TurboStreamActionsHelper
  def toast(text)
    turbo_stream_action_tag :toast, text: text
  end
end

Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
Enter fullscreen mode Exit fullscreen mode

We can now use turbo_stream.toast(...) in all the places where us usally can use turbo_stream.

For example we could use it in a controller action like so:

class ToastController < ApplicationController
  def index
    render turbo_stream: turbo_stream.toast("Hello world from Toastify!")
  end
end
Enter fullscreen mode Exit fullscreen mode

or in any ERB view/partial:

<%= turbo_stream.toast("Hello world from Toastify!") %>
Enter fullscreen mode Exit fullscreen mode

On the JavaScript-side we want add the npm package and define a toast custom action. Let's start by adding the npm package.

If you are using esbuild/webpacker you can use:

yarn add toastify-js
Enter fullscreen mode Exit fullscreen mode

If you are using Import maps you can use:

bin/importmap pin toastify-js
Enter fullscreen mode Exit fullscreen mode

In order for the alerts to show up properly we need to include the stylesheet in our <head>. For the sake of keeping this simple we are going to include the CDN version:

<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
Enter fullscreen mode Exit fullscreen mode

In our app/javascript/application.js we can define the toast custom action:

import { StreamActions } from "@hotwired/turbo"
import Toastify from "toastify-js"

StreamActions.toast = function() {
  const text = this.getAttribute("text")

  const toast = Toastify({
    text,
    duration: 3000,
    gravity: "top",
    position: "right",
    close: true
  })

  toast.showToast()
}
Enter fullscreen mode Exit fullscreen mode

Since this referes to a StreamElement, which is a regular HTML element, we can also use the getAttribute() function to get the value of the text attribute. Now we just need to pass it to the Toastify constructor. We are also passing over some additional arguments to control how the alert behaves.

With that we implemented a custom action for the toastify-js package. Now we can trigger toast alerts from the server-side. Here's how it looks like:

We can now go ahead and define attributes for the duration, gravity, position and close options in our Ruby helper. Since those are additional arguments we can define them using keyword arguments on our toast method and set some defaults values.

This allows us to override the options but doesn't require us to specify them everytime. The helper might look like this now:

module TurboStreamActionsHelper
  def toast(text, duration: 3000, gravity: "top", position: "right", close: true)
    turbo_stream_action_tag(
      :toast,
      text: text,
      duration: duration,
      gravity: gravity,
      position: position,
      close: close
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

If we call the helper like this...

turbo_stream.toast(
  "This is the text",
  duration: 5000,
  gravity: "botton",
  position: "center",
  close: false
)
Enter fullscreen mode Exit fullscreen mode

... it would generate the following <turbo-stream> element:

<turbo-stream
  action="toast"
  duration="5000"
  gravity="bottom"
  position="center"
  close="true"
></turbo-stream>
Enter fullscreen mode Exit fullscreen mode

(line-breaks added for readability)

Now we just need to adjust our custom action to account for the newly introduced options:

import { StreamActions } from "@hotwired/turbo"
import Toastify from "toastify-js"

StreamActions.toast = function() {
  const text = this.getAttribute("text")
  const duration = Number(this.getAttribute("duration"))
  const gravity = this.getAttribute("gravity")
  const position = this.getAttribute("position")
  const close = this.getAttribute("close") === "true"

  const toast = Toastify({
    text,
    duration,
    gravity,
    position,
    close
  })

  toast.showToast()
}
Enter fullscreen mode Exit fullscreen mode

And with that we have a fully configurable toast custom action we can invoke at any time from the server-side, very neat!

Actions using the target and targets attributes

As we've seen in the example above the toast action just takes the text attribute as an argument. It doesn't target and act on any element(s) on the page. If we want to write an action which acts on elements we need to write them differently.

Turbo Stream actions follow the convention that you can specify an HTML ID in the target attribute if you are just targeting one specific element and a CSS selector in the targets attribute if you are targeting multiple elements on the page.

Let's demonstrate this with a custom set_attribute action which sets an attribute and value on a target element. The StreamElement class offers a helper function we can use to support both the target and targets attributes at the same time.

We either want to use the target attribute to just target a single element with the ID post_1 ...

<turbo-stream action="set_attribute" target="post_1" name="author" value="Marco"></turbo-stream>
Enter fullscreen mode Exit fullscreen mode

... or use the targets attribute to target all elements matching the .post selector:

<turbo-stream action="set_attribute" targets=".post" name="author" value="Marco"></turbo-stream>
Enter fullscreen mode Exit fullscreen mode

Let's start with defining the barebones action:

import { StreamActions } from "@hotwired/turbo"

StreamActions.set_attribute = function() {
}
Enter fullscreen mode Exit fullscreen mode

We can grab the attribute name and value we want to set on the target element from the attributes on the <turbo-stream> element:

import { StreamActions } from "@hotwired/turbo"

StreamActions.set_attribute = function() {
  const name = this.getAttribute("name")
  const value = this.getAttribute("value") || ""
}
Enter fullscreen mode Exit fullscreen mode

And all we have left to do is to iterate over all target elements using the this.targetElements helper function the StreamElement class provides and call the setAttribute function on every element:

import { StreamActions } from "@hotwired/turbo"

StreamActions.set_attribute = function() {
  const name = this.getAttribute("name")
  const value = this.getAttribute("value") || ""

  this.targetElements.forEach(element => element.setAttribute(name, value))
}
Enter fullscreen mode Exit fullscreen mode

We don't need to worry which elements to select and can let Turbo handle it for us. This makes this action very straightforward.

The thing to note here is that an action like the set_attribute action is somewhat generic. It's generic enough that it could be used in any application which uses Turbo Streams.

That's why I've been working on a project which lets you include a set of ready-to-use Custom Turbo Stream Actions in your application with just a few lines of code.

Introducing: Turbo Power

At the end of August 2022 I started to implement a library with the goal to provide a "standard library of common custom actions". It's heavily inspired by CableReady and the operations it provides.

I've been on the CableReady Core Team for a few years and I've learned to love the power and flexibility it provides.

Personally I started to use StimulusReflex and CableReady in mid-2019, before Turbo Streams even existed. But the only reason which held me back from adopting Turbo Streams instead was because they were so limited in actions.

Now with Turbo 7.2 and Custom Actions that changed. I wanted to bring the same power and flexibility CableReady provides to the Turbo world and that's why Turbo Power exists.

It offers a dozen of common Stream Actions, including Actions to work with the DOM, Attributes, Events, the Browser, the History API, the Notifications API, Turbo Frames and more.

But don't worry, you don't need to include them all, you can enable and include them by category.

To get started you just need to add the npm package:

For esbuild/webpacker:

yarn add turbo_power
Enter fullscreen mode Exit fullscreen mode

For Import maps:

bin/importmap pin turbo_power
Enter fullscreen mode Exit fullscreen mode

And initialize it in your app/javascript/applicaton.js file:

import Turbo from "@hotwired/turbo"

// or: if you are using `@hotwired/turbo-rails`
import { Turbo } from "@hotwired/turbo-rails"

import TurboPower from "turbo_power"

TurboPower.initalize(Turbo.StreamActions)
Enter fullscreen mode Exit fullscreen mode

For the Ruby side you just need to add the turbo_power gem to your Gemfile with:

bundle add turbo_power
Enter fullscreen mode Exit fullscreen mode

And that's it. You now have a bunch of useful custom Turbo Stream actions in your arsenal, including the console_log and set_attribute actions we built in this blog post.

You can learn more about the Turbo Power project on GitHub: marcoroth/turbo_power.

There's also a companion gem for the use with Rails: marcoroth/turbo_power-rails.

Wrapping up

Custom Turbo Stream Actions are a game changer! I'm super excited about all the new possibilities Custom Actions enable and I'm curious how far we can go with approaches like this. In my opinion it has so much potential and we are just getting started!

Let me know what you are going to build with Custom Actions and don't hesitate to reach out if you have any questions about Custom Actions, Turbo Power or Turbo in general.

Thanks,
Marco

Top comments (0)