Hotwire/Turbo

Adding custom actions to Hotwire Turbo streams

(Note: This is about Turbo, a library that allows you to build a Single-Page-Application-like experience by using HTML fragments from the server to update parts of a web page, among other things.)


The constrained scope of the Turbo streams (the <turbo-stream> tags) is their biggest asset: Unlike returning the free-form JavaScript from the server (also known as the “server-generated JavaScript responses” approach,) it keeps everything consistent and thus easy to understand and maintain

But there is a pair of actions that I miss very much in my journey to reduce JS to the max: adding/removing classes. I don’t use Stimulus, so that’s not an option.

“You can replace the whole element by adding the new class” yes, I could, except that is super wasteful when all I want to do is adding a class to a big section or when I really can’t because the main content was lazy-loaded from a third-party API, and takes three seconds to render.

For adding those classes, I tried appending <script>s to the document using a stream, but they don’t run (because innerHTML blocks them for security). My life would be much easier if there was a stream action for doing it, so why not adding it myself?

Maintaining my forked version forever? No way. Luckily there is a simple way to add the actions without touching the Turbo library: hooking to the “turbo:before-stream-render” event.

At the point those events are fired, Turbo already has parsed the HTML returned by the server, detected that it contains stream tags, and created a document fragment for each one. Turbo fires a “turbo:before-stream-render” for each of those, just before attempting to execute the action.

So all we need to do is to detect if the action on a stream is one of our custom actions, tell it to Turbo to not process that stream with an event.preventDefault(), and then processing the stream ourselves.

For example, I want to add the “addclass” and “remclass” actions, for - you guess it - adding or removing a class to the target, so this is what I do:

const ExtraActions = {
    addclass: function (target, content) {
        target.classList.add(content)
    },

    remclass: function (target, content) {
        target.classList.remove(content)
    }
}

document.addEventListener(
    "turbo:before-stream-render",
    function (event) {
        const stream = event.target
        const actionFunction = ExtraActions[stream.action]
        if (!actionFunction) {
            return  // A built-in action, ignore
        }
        const target = getTarget(stream)
        const content = getContent(stream)
        actionFunction(target, content)
        event.preventDefault()
    }
)

function getTarget (stream) {
    if (stream.target) {
        return stream.ownerDocument?.getElementById(stream.target)
    }
    throw "target attribute is missing"
}

function getContent (stream) {
    // Quick and dirty method to extract the content of the
    // <template> tag.
    return stream.innerHTML.trim().slice(10, -11).trim()
}

For these custom actions, the content of <template> tag is the class to add or remove, and the target attribute the element on which do to it.

You could easily add even more custom actions, but you could go much further. You could replace ALL the actions with your custom versions and instead of using an id as a target, using a selector and pass it to document.querySelectorAll()… but as I said, having a small set of actions to remember brings consistency between projects and is one of the advantages of Turbo streams, so maybe you shouldn’t.

With the JS code above loaded on your page, now we can just emit streams with the “addclass” or “remclass” actions and they will work as if were part of Turbo.

<turbo-stream action="addclass" target="cart-drawer">
<template>open</template>
</turbo-stream>

✨ Like magic ✨. Enjoy!

Comments? Write me a quick email.
Written on
Juan-Pablo Scaletti

Hi I’m Juan-Pablo Scaletti

I’m a software writer and open-source creator. This is my corner of the Internet.