Turbo 7.2: A guide to Custom Turbo Stream Actions
Tags: turbo, hotwire, rails, ruby
Published: 2022-10-09 22:17:23

---
title: "Turbo 7.2: A guide to Custom Turbo Stream Actions"
published: true
description: A guide to Custom Turbo Stream Actions
tags: turbo, hotwire, rails, ruby
---

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](https://github.com/hotwired/turbo/blob/09d0bb1c4a9b1fea8e0e60844254ae2e7d02f605/src/core/streams/stream_actions.ts#L6) 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:

```html
<turbo-stream action="console_log" message="Hello World"></turbo-stream>
```

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

```javascript
// 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`](https://github.com/hotwired/turbo/blob/9e6e0b9992f0c19a39e7d6a47a8cc8c6c40ac9a8/src/elements/stream_element.ts#L30) 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](https://github.com/patrick-steele-idem/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`](https://apvarun.github.io/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:

```bash
rails generate helper TurboStreamActions
```

This generates us the following file:

```ruby
# 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:

```ruby
module TurboStreamActionsHelper
  def toast(text)
  end
end
```

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

```html
<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:

```ruby
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:

```ruby
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:

```ruby
class ToastController < ApplicationController
  def index
    render turbo_stream: turbo_stream.toast("Hello world from Toastify!")
  end
end
```

or in any ERB view/partial:

```erb
<%= 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:
```bash
yarn add toastify-js
```

If you are using Import maps you can use:

```bash
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:
```html
<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:

```js
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()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/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:

<img src="https://marcoroth.dev/images/toast.png" class="w-full">

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:

```ruby
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...
```ruby
turbo_stream.toast(
  "This is the text",
  duration: 5000,
  gravity: "botton",
  position: "center",
  close: false
)
```

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

```html
<turbo-stream
  action="toast"
  duration="5000"
  gravity="bottom"
  position="center"
  close="true"
></turbo-stream>
```
<div class="text-center text-gray-500 text-sm -mt-6">(line-breaks added for readability)</div>

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

```js
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` ...

```html
<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:

```html
<turbo-stream action="set_attribute" targets=".post" name="author" value="Marco"></turbo-stream>
```

Let's start with defining the barebones action:
```js
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:

```js
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`](https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute) function on every element:

```js
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

<img src="https://marcoroth.dev/images/heros/custom-turbo-stream-actions-turbo-power.png" class="mx-auto max-w-full mt-6 mb-6 rounded-md border border-slate-900/10" />

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](https://cableready.stimulusreflex.com/reference/operations) 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:
```bash
yarn add turbo_power
```

For Import maps:
```bash
bin/importmap pin turbo_power
```

And initialize it in your `app/javascript/applicaton.js` file:
```js
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:
```bash
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`](https://github.com/marcoroth/turbo_power).

There's also a companion gem for the use with Rails: [`marcoroth/turbo_power-rails`](https://github.com/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