Skip to main content

Turbo.js goes well with Golang

·6 mins
Kristof Kovacs
Author
Kristof Kovacs
Software Architect & DevOps Consultant

Hello, I’m Kristof, a human being like you, and an easy to work with, friendly guy.

I've been a programmer, a consultant, CIO in startups, head of software development in government, and built two software companies.

Some days I’m coding Golang in the guts of a system and other days I'm wearing a suit to help clients with their DevOps practices.

Recently I had the opportunity to reimplement the web UI of a 10+ years old project. The language is go, in which I don't do web UIs that often (since usually Laravel is much better technology for web UIs), and since I'm a fan of HTML-over-the-wire, I tried Hotwire Turbo from the 37signals team.

Hotwire Turbo is the Ruby world's version of Laravel's Livewire, but while Livewire and Laravel are deeply integrated, Turbo's Javascript parts are nicely decoupled into turbo.js, so it's fairly backend-independent.

I have to say, I ❤️ the library, and I totally recommend using it when you want to make fast web UIs with more server-side rendering than Javascript (one particular constraint of this particular project is that any non-static (i.e. npm-generated) JS is a no-no).

Hotwire Turbo basically has three (+1) parts, usable together or independently:

  1. Turbo Drive automatically changes your <a> links and <form>-s to submit/load content with AJAX, instead of performing a full page reload, making the site snappier.
  2. Turbo Frames lets you add <turbo-frame> tags to your HTML, and their content gets replaced with content received from the server side (for example, through a <form> Drive has changed to AJAX).
  3. Turbo Stream lets you add/remove/replace parts of the DOM by targeting any id, via automatic Turbo links, Websockets, or SSE.
  4. +1: Actually, they have a complementary JS library called Stimulus, which might be interesting for more JS-heavy projects, but then I would probably prefer either VueJS or the more lightweight Alpine.js (with which Turbo plays along nicely actually, according to my tests).

Turbo has a great documentation, too, the above concepts are very well explained here.

A quick summary of my experience with the library:

Non-CDN usage #

You are supposed to use turbo.js either via npm or from a CDN, but in this project this was not an option, so let's get the static source:

wget -O assets/turbo.js https://unpkg.com/@hotwired/[email protected]/dist/turbo.es2017-umd.js

Then we load the library like this:

<script type="module">
    import * as Turbo from "/assets/turbo.js";
</script>

For me, site-wide Turbo Drive was more a distraction. #

The idea of Turbo Drive is brilliant, and makes total sense for example, for content-management (CMS) systems. Using it in a fairly interactive, multi-screen app is not impossible, but it introduces an additional level of complexity: with Drive, JS is not "cleared" on page loads, so if you have different screens with totally different functionality, you have to explicitly handle:

  • Javascript from previous pages remains loaded on completely unrelated pages,
  • the user navigating "back" without your JS restarting (with or without caching),
  • potentially re-attaching listeners on changed content, etc.

I guess this is where one is supposed to use Stimulus maybe? :)

I ended up switching Turbo Drive off:

document.addEventListener("turbo:load", async (event) => {
    // Disable Turbo Drive
    Turbo.session.drive = false
})

and then "opting in" to Turbo functionality only where I needed it, by adding tags to links to prevent the full-page reload (and receive Frames or Stream instead):

<a href="..." data-turbo="true">...</a>

and for forms:

<form data-turbo="true" method="POST" action="...">...</form>

This way I get the best of the Turbo world, while not having to deal with the added "JS staying in memory" complexity.

But maybe Turbo Drive would be a useful addition to this site? 🤔 I might try that later.

Turbo Frames are brilliant and simple. #

The great thing about Turbo Frames is that using them is REALLY easy. You just write <turbo-frame> elements into your HTML:

<turbo-frame id="someid">
Click <a href="...">this link</a> to replace this frame!
</turbo-frame>

When the link gets clicked, Turbo loads the referenced URL via AJAX, and if a <turbo-frame id="someid">...</turbo-frame> is found in there, it replaces the previous frame with content of the new one, leaving other parts of the page unchanged. Very simple.

You can either send your whole page or just the frame parts for updates, and it will handle both cases perfectly.

According to my experiments, Turbo Frames handles hundreds of frames if necessary (although I would not go into thousands), the memory overhead is surprisingly low.

Note, there are some issues with turning <tr>-s into Turbo Frames -- turns out, attaching elements to a table has some unexpected rules in HTML, so research a bit before turning each row of your huge <table> into Frames, like I tried first. :) (I did manage to do the same with Stream later, though.)

Turbo Stream is the brilliantest, especially with Server-Sent Events. #

Up to this point you did't even need a "real" server-side, you can experiment with the functionality of Drive and Frames just by using .html files.

Turbo Streams is a somewhat higher effort thing, but incredibly powerful, especially combined with Websockets or SSE (server-sent events).

When using Turbo Stream for a server response, you send one or more "commands" to replace/append/prepend/etc a certain DOM id (target):

<turbo-stream action="replace" target="your-id">
    <template>
        [...your html here...]
    </template>
</turbo-stream>

(The <template> tag is mandatory, but it won't get into your DOM.)

For Turbo Stream to work, your AJAX requests need to accept text/vnd.turbo-stream.html content-type, which Turbo Drive does automatically, but I ended up creating two helper functions that allow me to load Turbo Stream messages from JS even without Turbo Drive:

// Helper function to send a GET and interpret the result as a Turbo Stream.
function MyGET(url) {
    fetch(url, {
        headers: {
            Accept: "text/vnd.turbo-stream.html",
        },
    })
        .then((r) => r.text())
        .then((html) => Turbo.renderStreamMessage(html));
}

// Helper function to send a POST and interpret the result as a Turbo Stream.
// Fetch documentation: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
function MyPOST(url, data) {
    fetch(url, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            Accept: "text/vnd.turbo-stream.html",
            "X-CSRF-Token": "...",
        },
        body: JSON.stringify(data),
    })
        .then((r) => r.text())

One thing that is a bit tricky if you want to trigger JS with your Stream, which is intentionally not done by Turbo Stream, as they say:

"Turbo Streams consciously restricts you to seven actions: append, prepend, (insert) before, (insert) after, replace, update, and remove. If you want to trigger additional behavior when these actions are carried out, you should attach behavior using Stimulus controllers. This restriction allows Turbo Streams to focus on the essential task of delivering HTML over the wire, leaving additional logic to live in dedicated JavaScript files."

I'm not super happy about this (I don't want to introduce their other library, Stimulus, just to execute some JS), but fortunately there is a workaround. I attach a <script> tag to another DOM element, then immediately remove it:

{{/* Reinstall drag and drop functionality */}}
<turbo-stream action="append" target="app">
  <template>
    <script id="reinstalldraganddrop_script">
    ReinstallDragAndDrop();
    </script>
  </template>
</turbo-stream>
<turbo-stream action="remove" target="reinstalldraganddrop_script"></turbo-stream> <!-- Prevents mem leak -->

Ultimately, I do recommend Hotwire Turbo when not using PHP -- in PHP, just use Livewire. :)