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.
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 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:
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
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 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.
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
npm install --save @hotwired/turbo --prefix assets
at the root of your project. Then open up
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
As a thought experiment, let’s think about why you’d want to have 2 separate endpoints for issues and comments.
- 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.
- 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.
- 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
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
to pick out sections of the response and
replaces just those portions in line! Everything else is
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
turbo-frame request header and only sending the relevant part of the page
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.
- MORE PLUGS! Here we’re going to added a value into
- If we can handle a stream for the response we…
- add the
text/vnd.turbo-stream.htmlcontent-type in the response. THIS IS CRITICAL! Had to reach out the the great Jamie Gaskins for help on this bit. THANKS!
- 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
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
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.
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.
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
mix phx.gen.channel Comment, and add
channel "comment:issue:*", ThirdRailWeb.CommentChannel
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
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.
- Listen for the
- Disconnect from our channel, if we’ve already connected
- See if we’re on a page for an issue
- Connect to the comments channel for that issue.
- Respond to the
new_commentmessage 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
joinChannel along with the message handler.
Let’s see what that gets us.
Yey? We’re still inserting the
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!
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 Copyright © 2021 Steven Nuñez - HostileDeveloper