The anatomy of a Turbo Stream

Professor Benjamin Buttersworth teaching us about Turbo Streams

The <turbo-stream> element is pretty magical. Have your server respond with one or more <turbo-stream> elements and your DOM changes automatically. You can specify with one word (per element) what you want to happen and without writing any JavaScript, remove elements, add new ones, replace content and so on.

Magic is cool, but we must seek to understand its inner workings if we are to wield Hotwire and Turbo effectively. Why and how does a <turbo-stream> make the DOM change? What do Turbo Streams have to do with web sockets? What even is a <turbo-stream>?

There are two reasons why I think it is useful to gain a deeper understanding of frameworks (if you need any reasons, that is). One, you get more comfortable debugging weird issues. What may seem inexplicable or puzzling when you don’t fully understand a piece of tech’s inner workings becomes transparent once you do. Two, you supercharge your ability to create “options” for yourself when the framework or tool, as written, doesn’t do what you want. Because you understand the inner mechanisms, you are able to introduce new functionality and customize the tool with confidence. Later in this article, we’ll see a simple example of such an extension with Turbo Streams.

What is a <turbo-stream>?

In the HTML that you probably learned when starting out in web development, there’s the concept of “built-in” elements. Things like <div>, <p>, <textarea>, <input>, and so on. You give a browser some text containing these elements, and assuming you’ve got the syntax right, the browser knows what to do with them, and has a specific thing that it does for each element. For example, <input type="number" /> will make the browser draw a number field.

Custom Elements give you the ability (a JavaScript API, in other words) to either extend the behavior of built-in elements or define your own elements with completely custom behavior. They were introduced circa 2011 as part of the Web Components umbrella of technologies. <turbo-stream> is one such custom element. It is an “autonomous custom element”, which means its behavior is fully custom and does not inherit from any builit-in HTML element.

Custom Element Basics

Let’s take a quick look at the essential building blocks of an autonomous custom element. This will prepare us to understand what the <turbo-stream> element does. It’s easy to make a custom element. You need:

  1. A tag name for your element. duck-alert for example.
  2. A class, which extends the HTMLElement generic class. This class will specify the behavior for your custom element.

Once you have these two things, you “register” this custom element with the Custom Element Registry like so:

customElements.define("duck-alert", DuckAlertElement) // customElements should be available in your browser without needing to import anything

By inheriting from HTMLElement, our custom element class gets access to various callbacks that are invoked when the element is in different stages of its lifecycle. The one <turbo-stream> uses, and the one we’ll take a look at now, is called connectedCallback(). This method is called when the element is appended to the DOM.

Let’s say we want an alert to pop up with some custom text when the <duck-alert> element is added to the screen. For example, if this text gets added to the DOM:

<duck-alert duck-type="Large" />

I want to see an alert saying “Large duck spotted!”. That is simple enough to do.

class DuckAlertElement extends HTMLElement {
  connectedCallback() {
    const duckType = this.getAttribute("duck-type")
    alert(`${duckType} duck spotted!`)
  }
}

To see if this actually works, I can do the following in the browser’s dev console (after pasting in the above class definition):

customElements.define("duck-alert", DuckAlertElement)
da = document.createElement("duck-alert")
da.setAttribute("duck-type", "Large")
document.body.appendChild(da)

If you’re following along, the moment you append the element to the document body, you should see an alert about a large duck show up. Pretty neat, eh?

How does a <turbo-stream> change the DOM?

Here’s what an example <turbo-stream> fragment looks like:

<turbo-stream action="replace" target="message">
  <template>
    <div id="message">
      This div will replace the existing element with DOM ID "message"
    </div>
  </template>
</turbo-stream>

We can see that there are three “inputs” to the custom element. Two inputs are attributes: action and target. The third input is a child element of type template.

Now that we know how custom elements work, we can venture to write a super simple version of <turbo-stream>. Doing so will help us gain a better understanding of what the “real” <turbo-stream> does. Our custom element will be called <replacement-stream> and all it can do is replace the given target’s contents with the contents nested within it.

class ReplacementStream extends HTMLElement {
  connectedCallback() {
    const target = document.getElementById(this.getAttribute("target")) // Assume target always exists to keep this example short
    const content = this.firstElementChild.content // Assume firstElementChild is always a <template> element
    target.innerHTML = ""
    target.append(content)
  }
}

Like before, to be able to use this element, we’ll need to register it:

customElements.define("replacement-stream", ReplacementStream) 

We can then try it out like so:

// create a target, or pick one that already exists on the page. Then...
rs = document.createElement("replacement-stream")
rs.setAttribute("target", "my-replacement-target")
rs.innerHTML = "<template><p>New content</p></template>"
document.body.appendChild(rs)

And, boom, just like that, we have a basic replace action going. In other words, whenever an element like this -

<replacement-stream target="my-replacement-target">
  <template>
    <p>
      Replace target content with me.
    </p>
  </template>
</replacement-stream>

- is added to the DOM, we can expect that the connectedCallback method of ReplacementStream will be called, and that method will make the replacement happen.

A <turbo-stream> is more capable than our <replacement-stream> element, but it follows a similar two-step approach to changing the DOM:

  1. One or more <turbo-stream> elements are added to the DOM. One of Turbo’s functions is to intercept form submissions and link clicks. If Turbo sees that the response from the server is of the format text/vnd.turbo-stream.html, then it will add the contents of the response to the DOM.
  2. Once the <turbo-stream> elements are added, they trigger the connectedCallback method of the StreamElement class. Using some JS metaprogramming, the action attribute maps to a function that performs the desired action (like replace, append etc)

I’d encourage you to take a look at the linked source. It should hopefully be familiar to you now that we’ve gone through the basics of connected elements.

What does <turbo-stream> have to do with WebSockets?

In practice, it doesn’t have anything to do with WebSockets. <turbo-stream> is a custom element which when added to the DOM triggers some JavaScript that changes the DOM in some way. The cool thing about this is that it doesn’t matter where the custom elements come from. Just like Turbo intercepts form submissions and link clicks, it also has the ability to intercept websocket streams that you’ve subscribed to. If it sees <turbo-stream> elements being sent, it will add them to the DOM.

If you’re using Rails, you might have seen the turbo_stream_from helper. This helper generates another custom element called <turbo-cable-stream-source>. This element, when added to the DOM, subscribes to the WebSocket channel that is specified in it’s attributes.

Bonus: Adding a custom Turbo Stream action

By default, Turbo Streams support seven actions: append, prepend, replace, update, remove, before and after. What if we had ideas for things Turbo Streams could do that fall outside these default actions? Can we write our own actions? It turns out that we can.

Let’s take a look at the metaprogramming that invokes a given action.

// https://github.com/hotwired/turbo/blob/main/src/elements/stream_element.ts
get performAction() {
  if (this.action) { // this.action is a getter for the action attribute
    const actionFunction = StreamActions[this.action] // --> To add a custom action, we need to add a method to `StreamActions`
    if (actionFunction) {
      return actionFunction // actionFunction is called later
    }
    this.raise("unknown action")
   }
   this.raise("action attribute is missing")
 }

StreamActions is a plain old JavaScript object that is made available to us by Turbo. We can import it, and add our actions to it as a new method. Here’s an example taken from the Turbo Handbook.

import { StreamActions } from "@hotwired/turbo"

// <turbo-stream action="log" message="Hello, world"></turbo-stream>
//
StreamActions.log = function () {
  console.log(this.getAttribute("message"))
}

A couple of important things to note here:

If you’re curious, I also recommend taking a look at my article on redirecting out of a Turbo frame. One of the solutions involves adding a custom “redirect” action to <turbo-stream>.

Conclusion

I hope this article has demystified some of Turbo’s inner workings for you. A Turbo Stream element, when it comes down to it, is a piece of HTML (known as a Custom Element) that triggers some “basic” DOM-changing JavaScript when it is added to the DOM. You can even add your own behavior to <turbo-stream> by adding methods to the StreamActions object.

If you got value out of this article and want to hear more from me, consider subscribing to my email newsletter to get notified whenever I publish a new article.

Want to be notified when I publish a new article?