Marco Roth
Posted on October 9, 2022
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>
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)
}
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
This generates us the following file:
# app/helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper
end
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
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>
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
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)
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
or in any ERB view/partial:
<%= turbo_stream.toast("Hello world from Toastify!") %>
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
If you are using Import maps you can use:
bin/importmap pin toastify-js
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">
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()
}
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
If we call the helper like this...
turbo_stream.toast(
"This is the text",
duration: 5000,
gravity: "botton",
position: "center",
close: false
)
... it would generate the following <turbo-stream>
element:
<turbo-stream
action="toast"
duration="5000"
gravity="bottom"
position="center"
close="true"
></turbo-stream>
(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()
}
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>
... 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>
Let's start with defining the barebones action:
import { StreamActions } from "@hotwired/turbo"
StreamActions.set_attribute = function() {
}
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") || ""
}
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))
}
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
For Import maps:
bin/importmap pin turbo_power
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)
For the Ruby side you just need to add the turbo_power
gem to your Gemfile
with:
bundle add turbo_power
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
Posted on October 9, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.