Phoenix on Turbo

Ok, so there’s a lot of excitement around Hotwire, a way of building web apps by focusing on sending HTML Over the WIRE (get it!?). Hotwire and the supporting library Turbo came out of building hey.com, an email service. It was built to make Rails development snappier, and more dynamic. It also allows for an easy way to build mobile apps backed by your webapp.

What’s interesting is that it’s not just limited to Rails. Since the whole idea is sending HTML down to the client, we can use our favorite server side language to generate that markup and join in on the fun.

Now, when I say “snappier and more dynamic” you say LiveView. I get it. In this post, we’ll discuss why this approach is a good way of getting high performance page rendering and better composibility out of your existing request/response app.

Photo by Chris Rhoads on Unsplash

What is Turbo?

Turbo is made up of 3 components available in the @hotwired/turbo js library: Turbo Drive, Turbo Frames, and Turbo Streams.

Turbo Drive

Turbo Drive gives your pages a nice boost for “free”. It converts all of your http calls into XHR requests and replaces to body in line. If you think this sounds a lot like turbolinks, you’re right! Turbo Drive is the next generation of that concept.

You’ll notice I used free in quotes. Turbo Drive places state in the browser. From the Handbook:

Turbo is fast because it prevents the whole page from reloading when you follow a link or submit a form. Your application becomes a persistent, long-running process in the browser. This requires you to rethink the way you structure your JavaScript.

As Phoenix developers you’ll notice one key difference between this and LiveView. Where state lives. BOTH are long lived processes and require us to change parts of how we build applications. With Turbo Drive, you can’t rely on a page to load and setup your event handlers/socket connections etc. You’ve got to hook into the turbo:load event for setup and teardown. We’ll see this in action when we talk about Turbo Streams over Channels.

The cool thing about Turbo Drive is that there’s no code other than adding import * as Turbo from "@hotwired/turbo" into your app.js file.

Turbo Frames

This is probably my favorite Turbo feature. FRAMES! Turbo Frames let you compose web pages from several endpoints, letting you reuse your already existing markup on different pages. This is huge! If you’ve got existing code HTML endpoint, you can start to mix and match them using Turbo by just marking up your… markup. Also, async requests are a snap too. Just add an src attribute and it will it the endpoint and load the results in-line.

Turbo Streams

Turbo Streams allow you to send fragments of html via WebSockets/Server Side Events. You’ll receive a fragment of html that, once you add it to the dom, will position itself relative to an anchor element and a directive. Sounds like a lot, and to be honest, it takes some work to get it wired up. While talking to the rest of my brain Sophie DeBenedetto, she called out that there’s a lot of boilerplate to get this working with Phoenix Channels vs just using LiveView. I think one thing to keep in mind here is that Chris McCord worked his butt off to make LiveView feel seamless. I’m sure we can abstract this away to make it just as nice.

OK, let’s build a thing.

Issue Tracker

We’re going to be adding Turbo to a simple issues app. It’s not tied to anything so really this is just a therapy app where your friends can comment on your issues. You can pull down the starter code here. As it stands, it’s pretty unusable.

This is pretty standard scaffolding. You get to your first wart when you create a new comment. Issues and Comments are handled by 2 different controllers and require you to hit 2 different routes.

Let’s add Turbo to see if we get any wins out of the box

Adding Turbo

Run npm install --save @hotwired/turbo --prefix assets at the root of your project. Then open up app.js and include this.

Let’s see what we get.

Still terrible… but it’s fast. You can see that all requests to our backend were made via AJAX, with portions of our page’s <body> being replaced in-line.

Let’s start moving towards making this… less terrible by using Turbo Frames

Showing Comments

As a thought experiment, let’s think about why you’d want to have 2 separate endpoints for issues and comments.

  1. If your authorization logic is complex enough, deciding which comments to render and how people can interact with them can become a pretty hairy bit of code. Keeping them separate allows your endpoint to focus on doing one thing well.
  2. Speed. The data I need to render an issue is minimal. Bringing in comments on that initial page load along with pagination logic can slow the page down significantly.
  3. None of these things are real for our stupid example, but leave a comment on when you’ve seen this in the wild and why you did it!

These reasons could be the best ones in the world, but no one cares because it’s unusable. Let’s fix that.

Update the comments index.html.eex to look like this:

And replace update the Issue’s show.html.eex template to look like this.

THIS IS COOL! Think of those <turbo-frame> elements as landing pads. Turbo uses those tags along with the id field to pick out sections of the response and replaces just those portions in line! Everything else is ignored.

In our example, we’re returning the whole page back; layout and all. We can prevent the layout from being rendered by looking for the the turbo-frame request header and only sending the relevant part of the page back.

Now you get back just the comments index, without the layout!

This still kind of sucks though. What is this? the 90s? I want my page to load, and I want a spinner! For this to preload, all we need to do is change the issue’s show.html.eex to this.

Let’s see what this buys us, then we’ll go over the code. The takeaway from here is that we’ve only added 5 lines of markup, a plug to make it a bit quicker. The plug was optional!

I think we’re starting to get less terrible. GO TEAM. Let’s move on to Streams.

Streams or Screams

So far, I’ve been cheating a bit. If you take a look at our CommentController, I redirect back to the issue. This makes the page refresh, reload the comments and we’re just about ready to accept our UX awards. Streams present us another opportunity to do something nice here. If we wrap our form in a <turbo-frame>, an accept header of text/vnd.turbo-stream.html is added to the request. If we send back a specially formatted response, along with a response content type, it integrates the response on the page.

Let’s modify our controller to check for this header, and send down a specially crafted message in response to a form submission.

Following along with the numbers.

  1. MORE PLUGS! Here we’re going to added a value into conn.assigns.
  2. If we can handle a stream for the response we…
  3. add the text/vnd.turbo-stream.html content-type in the response. THIS IS CRITICAL! Had to reach out the the great Jamie Gaskins for help on this bit. THANKS!
  4. Finally we render a template that adds the magic bits for the comment to wind up in the right place on the DOM.

Here’s the template:

From the docs you can see our action options are append, prepend, replace, update, and delete. They do what you expect. The target is the id of the element you’re… well, targeting.

One last bit is you have to wrap the form in a <turbo-frame>.

Now we get this!

Streams are a great feature, but we need to smooth this out if we want any real usage.

There’s one more bit of work we need to add to complete our Turbo Tour. Updated comments over channels.

Overview

Ok, so this is going to be a lot. We’re going to join a channel tied to an issue’s comments. If we visit a different issue, we’ll need to disconnect from that channel and join the new channel.

We’ll update the create_comment_for_issue function in our Core module to emit a new_comment message with the id. In the channel, we’ll look up the comment, and follow the Hotwire philosophy by sending down the HTML we want on the page.

Oh, and we’ll have to convert the string of markup into DOM elements and add them to the body.

Like I said… a lot.

Photo by Claudia Wolff on Unsplash

Setting up Channels

Channels are kind of magic in that unlike with LiveView, we don’t need to manually subscribe via PubSub, and write our own handle_info function to update state. If we broadcast a message to a topic matching the channel name, unless we intervene, that message will be sent down to the client. To be clear, by joining the comment:issue:9 channel, we will receive all messages broadcast to the comment:issue:9 topic.

Let’s run mix phx.gen.channel Comment, and add channel "comment:issue:*", ThirdRailWeb.CommentChannel to user_socket.ex.

Let’s update the channel to look like this:

We don’t have to do anything with issue_id on join, but that’s where… security happens. We’ll intercept the new_comment message, and instead of just broadcasting the id to the client, we’ll convert it to the template we want on the page.

A quick glance at the broadcasting code:

Now onto the client!

Setup and Teardown

Let’s add a data element to the issue so we know which channel to connect to. Open up the issues show.html.eex file, and change the header to <h1 id="issue" data-issue-id="<%= @issue.id %>">Show Issue</h1>. This will give us a way of knowing we’re on a page for a specific issue, and which issue it is.

In the JavaScript, we’ll

  1. Listen for the turbo:load event
  2. Disconnect from our channel, if we’ve already connected
  3. See if we’re on a page for an issue
  4. Connect to the comments channel for that issue.
  5. Respond to the new_comment message by appending it to the DOM. Turbo does the rest.

We’ll listen for that turbo:load event, and try to disconnect. If the page we’re on is an issue, we join that channel. Here’s disconnectChannel and joinChannel along with the message handler.

Let’s see what that gets us.

Yey? We’re still inserting the <turbo-stream> response from the form. We can get rid of that to remove the duplicate comment. Revert it back to the previous version and let’s take a look.

VoilĂ ! We’re getting messages from a websocket and adding them to the page! This also got us cross browser updates!

Wrap

I really like how Turbo makes composing applications easy. I see this as an analog to using LiveView Components to compose a larger View. And the additional benefit of using your existing request/response endpoints is compelling.

If you have a document based dashboard with some light interactivity, and you’ve found yourself reaching for React, maybe give Turbo a try with some Stimulus sprinkled in.

Personally, I’d see having to add high interactivity to a page in Phoenix as the time to reach for LiveView.

Is Hotwire the future and is it worth adding it to your Phoenix apps? Leave a comment and let me know what you think.

comments powered by Disqus