Hotwire is central to how we drive the frontend at FreeAgent – the combo of Turbo and Stimulus helps to simplify how we combine HTML and JS in Rails to drive the user experience. While previously we have used React and jQuery, the latter of which we have totally removed, Rails encourages us to deliver the frontend primarily through serverside rendering. By sending HTML over the wire, rather than a single-page app with JS, we build something that naturally complements the Rails framework.
Turbo affords us plenty of ways to dynamically update the frontend. When it first launched, particular priority was given in the docs to Turbo Streams, custom HTML elements which would amend the page in some way when added to the DOM. Initially, these were only responsible for manipulating elements on the page, but an additional Turbo Stream was made available to allow you to refresh the page.
We’ve recently begun to make use of Action Cable, Rails’ WebSocket implementation, on the platform. This enables us to broadcast Turbo Streams to browsers which will update pages asynchronously for users. Sending Turbo Streams as the user browses the site adds a layer of richness to the user experience.
One of these things is not like the others
Turbo Streams are custom HTML elements that specify an action. When they’re added to the DOM, they’ll manipulate the content of the page based on their action — appending content to an existing element, replaceing an element entirely, or perhaps removeing something from the DOM, for example.
<turbo-stream action="update" target="modal">
<template>
<!-- Some HTML content here -->
</template
</turbo-stream>
These actions I’ve listed were among the original set of actions available in the first release of Turbo. You could respond to a request with a Turbo Stream as an alternative to rendering an entire view, allowing for more unique interactions for clients that use JS, as opposed to navigating to separate pages.
The introduction of the Turbo Stream refresh action, which instructs the browser to refresh the page, came about three years after these. It noticeably stands out from the original set of actions which explicitly update the existing DOM, including append and update. refresh, by contrast, will trigger a new request to the current path — it’s fundamentally different as it initiates a request rather than manipulating the contents of the existing DOM tree directly. To quote the original docs from 2020:
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.
Knowing this was the original philosophy, and having used Hotwire since its inception back then, I had long been curious as to why the refresh action now existed. After all, redirecting back to the current page is essentially equivalent to refreshing it — both will initiate a GET request to the path you’re already on.
class MacguffinsController
# GET /macguffins, macguffins_path
def index; end
# POST /macguffins
# If we render a form on the index page that POSTS to
# this action, because we redirect back to the index
# page, we essentially get the same effect as
# refreshing the page - we're rendering the same view
# that is already being displayed to the user.
def create
respond_to do |format|
format.html { redirect_to macguffins_path }
# ...which makes using a refresh action like this redundant.
format.turbo_stream { render turbo_stream: turbo_stream.refresh }
end
end
end
If we were to instead respond with a turbo-stream action="refresh" as above, it would only be able to be consumed by browsers that are using JS, since that’s required to use Turbo. We might as well return the plain HTML equivalent at that point. So what’s this thing for?
Mighty morphin’
This action was introduced in PR #1019 on the Turbo repo by Alberto Fernández-Capel (a former FreeAgent, no less!), and the reasoning was this:
This will offer a simplified alternative to fine-grained broadcasted [Turbo Stream] actions.
So this was specifically introduced for use when broadcasting using Action Cable. In this scenario, we’re already in a world where the client supports JS — they need it to connect using Action Cable — so it’s fine for that instruction to be sent as a Turbo Stream. And unless we write more JS of our own, we don’t have a way of mirroring the pattern you see in the controller example I showed above. So that’s what the refresh action is for – allowing us to get the new page state from the server via broadcast messaging.
If your site isn’t using Turbo, navigation typically isn’t always the smoothest — scroll position might move, and there might be brief flashes of unstyled content where assets haven’t loaded. Turbo does a lot to remedy these things, but particularly when we’re refreshing the same page and we can expect the content not to have significantly changed, there’s an opportunity to be smarter about how it’s updated. Refreshing the same page calls for a more intelligent way to handle refreshes: it’s morphin’ time!
How does it work?
Morphing is an aspect of Turbo that can save you having to use more targeted Turbo Streams like append and update. When the page is refreshed, you can choose to do so intelligently and replace only the elements that have changed in the refresh response. The underlying idiomorph library that’s responsible for this has the following to say about its algorithm:
[…] before node-matching occurs, both the new content and the old content are processed to create id sets, a mapping of elements to a set of all ids found within that element. That is, the set of all ids in all children of the element, plus the element’s id, if any. Id sets can be computed relatively efficiently via a query selector + a bottom up algorithm.
From there, the intersections of ID sets can tell you whether two elements match.
It’s thankfully not a detail that most folks will need to be concerned with, which speaks to the ease of use of morphing, but it’s interesting to note.
What does this mean for Rails in the frontend?
What you end up with, if you rely on broadcasting Turbo Streams that refresh the page, is something that is primarily driven by serverside rendering (like an out-of-the-box Rails app), yet flexible to realtime interaction. Combine that with modern enhancements to CSS, and you can build interactive experiences that look and feel great without immediately needing to use something like React, let alone write a lot of your own JS.
Enemy of the state
All this aside, let’s say someone was pressing your F5 key every second. If your page was refreshing without your input, it’d be pretty frustrating, right? You’d barely be able to find an option in a dropdown before it disappears, you wouldn’t be able to confirm a modal, and if you were uploading a file somewhere, then the upload might not complete before a competing refresh request were to cancel it.
These things all constitute the current state of the DOM (document object model). The state of the DOM lives purely in your browser tab and would be lost if you refreshed the page. The initial state is dictated by what lives on the server, but it can be changed on its own. This is why if you use Inspect element to edit what’s shown on a page, it doesn’t persist after navigating elsewhere. If the state of something on the page is vulnerable to a manual refresh, then it’s vulnerable to morphing triggered by a broadcast refresh Turbo Stream, too.
Save states
Thankfully, Turbo provides an option to effectively ignore morphing for given elements – the data-turbo-permanent attribute. Elements with data-turbo-permanent will be skipped when a morph occurs, preserving their state. There are also JS events related to morphing elements, which can be prevented using preventDefault if you only wish to prevent morphing under certain conditions.
We’ve had to think about preventing morphing in a few scenarios:
- Open dropdowns shouldn’t close or change under the user’s mouse, so we prevent these from morphing with the acceptance that they might be outdated until the page next renders.
- Modals, such as confirmations we display when deleting items, also shouldn’t be closed by refreshes. When we’ve done this, we need to make sure any underlying forms they might trigger aren’t vulnerable to morphing either — if the form is removed from the page, then accepting the modal triggers nothing!
- Stateful JS-driven elements, like our drag-and-drop upload form, may want to remain on the page if they have long-running state that they manage entirely themselves.
But what if you’re in a right state?
We’ve thought about the above approach when there’s a lot of server-driven state and less DOM state. But for pages where morphing would destroy swathes of DOM state, we’ve found it better to make more targeted updates to the page. This can be done by instead broadcasting Turbo Streams that change the DOM (update, replace, etc.) or working with Turbo Frames.
It can be useful to create Turbo Frames to silo off parts of the page and reload them in isolation. This is one use case for a custom Turbo Frame action, like the turbo_frame_reload action from turbo_power. That’s a handy resource for other custom Turbo Stream actions you may wish to introduce depending on your needs.
Where a full-page refresh creates a situation where you may need to exclude elements from morphing, using other Turbo Stream actions or Frames lets you mark out elements or sections of the page that should be updated, leaving all other DOM state alone. That way, you can sidestep areas that might need more work, and let sleeping DOMs lie.
Updating the page asynchronously with broadcast page refreshes opens the door to more interactivity, and it encourages us to consider the impact of frontend state to write more simplistic view code. If you’re planning on making use of Action Cable alongside Turbo yourself, hopefully some of the learnings we’ve made on our journey with Action Cable and Turbo so far will help you too.
Feeling refreshed? Drop a comment if there’s anything more you’d like to know!