Using Hotwire Turbo in Rails with legacy JavaScript
Matouš Borák
Posted on April 23, 2021
When Hotwire Turbo got released around Christmas 2020, it was exciting news for many of us. One of its main appeals is that it helps you create highly reactive web pages in Rails while having to write almost no custom JavaScript. Turbo also seems very easy to use, it just ”invites“ you to try and play with your pages. Let’s take a look if Turbo can be used in a long-developed project with a lot of old JavaScript code, too (spoiler: with a little tweak, it very much can!).
The road to legacy JavaScript in a long-time Rails project
After all the years that we watched the JavaScript community boost its ecosystem to tremendous heights and after trying (and often failing) to keep up with the pace of language enhancements, new frameworks and build systems, this intended simplicity of Turbo is a very welcome turnaround. To be clear, we do like JavaScript, it’s a fine language, especially since ES6, but in our opinion its strengths stand out and are sustainable only if you have enough sufficiently specialized JavaScript devs in a team. In other words, for a small Rails team, long-term management of complex JavaScript can be very difficult.
That’s why we have always been cautious about bringing too much JavaScript to the project, especially for things that could be done in other ways. Still, there's always been a kingdom where JavaScript absolutely ruled and that was page reactivity. Most people love reactive pages and we do, too! So, in the end, still a lot of JavaScript managed to get into our codebase.
Over the years, the ”official“ support and default conventions for building reactive JavaScript-enabled pages in Rails took many different forms. Let’s just run through some of the options for working with JavaScript that we had in our pretty much standard Rails project during the course of its existence, i.e. during the last ~12 years:
- there was the old and rusty inline vanilla JavaScript since forever,
- there was the Prototype library since who knows when but it was phased out gradually (~2010),
- and in Rails 3.1, it was replaced by jQuery (~2011),
- Rails 3.1 also brought CoffeeScript as a new and encouraged way of ”writing JavaScript“ (~2011),
- there was Unobtrusive JavaScript to replace the inline style; it was pushed further by the jquery-ujs library (~2010), later superseded by the somewhat compatible Rails UJS (2016),
- there were Server-generated JavaScript Responses (SJR) allowing the server to update pages via JavaScript (~2011),
- since Rails 4, the Turbolinks library has been included but had a bunch of problems at that time (2013), so
- Rails 5 came with a major and largely incompatible rewrite of Turbolinks (Turbolinks 5), the previous versions of which were renamed to Turbolinks Classic (2016),
- Rails 5.1 optionally adopted the webpack bundler and the yarn package manager (2017), the two became the preferred way of handling JavaScript in Rails,
- Rails 5.1 also removed jQuery from default dependencies (2017)
- the Stimulus JS framework was released (2018),
- CoffeeScript, although still soft-supported via a gem, is discouraged in favor of vanilla ES6 JavaScript or Typescript compiled via webpack (~2018),
- after being in beta for 3 years, Sprockets 4 was released, with support for ES6 and source maps in the asset pipeline (2019), to serve people still hesitant with webpack,
- and finally Turbo which should become a part of Rails 7 (late 2020),
- oh and by the way, DHH nowadays explores native ES6 modules which could allow ditching webpacker and returning to Sprockets for handling JavaScript again.
- update as of Aug 2021: the webpacker-less native ES6 module imports way of handling JavaScript is going to be the default in the future Rails versions.
What a ride! In retrospect, to us it really looks as if DHH and others struggled hard to make the JavaScript ecosystem and its goodies available in Rails but not until they were able to come up with a sufficiently elegant way to do that (and if so, thanks for that 🙏). Each iteration made sense and each newly adopted technique was a step forward but still, the overall churn of JavaScript styles has been tremendous. While, in our experience, upgrading Rails itself got easier with each version, the same cannot be said about our JavaScript code. JavaScript in Rails from only a few years ago is quite different from how it looks today.
Turbo changes everything
And here comes Hotwire Turbo to change the situation again but this time with truly good promises. The reasoning for high hopes is simple: Turbo lets you create many of the reactive page patterns without having to write a single line of JavaScript. JavaScript is now pushed behind the scenes and the main focus, even for describing reactive behavior, is on HTML which is easy to author via Rails templates (or anything else). Custom JavaScript code, now preferably written as Stimulus JS controllers, becomes just an icing on the cake if you need some more special interactions with a page.
The new Basecamp’s flagship – the HEY.com service – currently uses a total of ~60kB of JavaScript (zipped) while, in terms of reactivity, it feels like a real SPA. In contrast, our web uses twice as much JavaScript while mostly being an ordinary click-and-wait-for-the-whole-page web, oh well…
So again, with Turbo, the problem with JavaScript code patterns becoming obsolete is effectively gone because in the future there will simply be no custom JavaScript code to upgrade!
If it all looks that great, why were we hesitant so far about just adding the turbo-rails
gem and hitting the shiny new road? Before we actually tried to dive in, we had the following big concern: will Turbo work with Turbo Drive disabled? Turbo Drive, the successor of Turbolinks, is a member of the Turbo family. This library is cool but requires the JavaScript code to be structured in a certain way which is often quite hard to achieve in an older project with a lot of legacy JavaScript. We haven’t really tried to bite the refactoring bullet yet, although we’re getting near. Until then, we need to be sure that our web will work OK without Turbo Drive.
And we are happy to find out that the brief answer to this question is a big bold YES! Read on if you’d like to know more.
Installing Turbo
We won’t go into much detail here, the official procedure just worked for us. If you’re still using the Asset Pipeline for your JavaScript files, make sure it supports ES6 syntax (i.e., you’ll need to upgrade to Sprockets 4). You also need a recent-enough Rails version (Rails 6, it seems). Otherwise, all should be good.
One small catch though: if you have both the Asset Pipeline and webpack enabled (as we do) and if you only want Turbo to be included in the webpack-managed bundles, you’ll notice that turbo.js
gets precompiled also in the Asset Pipeline if you use the turbo-rails
gem. It turns out that the gem automatically adds this file into the pipeline upon initialization. To prevent this (and save a bit of hassle with enabling ES6 in Sprockets), you can remove it again during the start of your Rails app:
# config/application.rb
class Application < Rails::Application
...
# remove Turbo from Asset Pipeline precompilation
config.after_initialize do
# use this for turbo-rails version 0.8.2 or later:
config.assets.precompile -= Turbo::Engine::PRECOMPILE_ASSETS
# use this for turbo-rails versions 0.7.1 - 0.8.1:
config.assets.precompile.delete("turbo.js")
# or use this for previous versions of turbo-rails:
config.assets.precompile.delete("turbo")
end
end
Note that the proper asset name is dependent on the turbo-rails
gem version so choose only one of the config lines. This commit in v. 0.8.2 added a handy constant so that it’s easier to opt-out of assets precompilation.
Disabling Turbo by default
If you try browsing your site now, after some time you’ll likely notice various glitches and unexpected behavior – that’s Turbo Drive (Turbolinks) kicking our legacy JavaScript butt. What we need to do now is disable Turbo by default and enable it selectively only in places where we’ll use Turbo Frames or Streams.
Update: since Turbo 7.0.0-rc.2 this is an officially supported option, before that we needed to do a little trick.
Disabling Turbo 7.0.0-rc.2 or later
Since this version, we can make Turbo opt-in globally via this line in a JavaScript pack:
// app/javascript/packs/application.js
import { Turbo } from "@hotwired/turbo-rails"
Turbo.session.drive = false
And that’s it!
Disabling previous versions of Turbo
For those of us still on Turbo 6, we’ll need to take a slightly different approach. We’ll do the disabling part in a little conditional way that will help us when we try to make our JavaScript code Turbo Drive-ready later. To disable Turbo completely in all pages in Rails, you can put the following instructions in your layout files:
<%# app/views/layouts/application.html.erb %>
<html>
<head>
<% unless @turbo %>
<meta name="turbo-visit-control" content="reload" />
<meta name="turbo-cache-control" content="no-cache" />
<% end %>
...
</head>
<body data-turbo="<%= @turbo.present? %>">
...
</body>
</html>
The instructions here are all controlled by the @turbo
variable. If you do nothing else, this variable will be equal to nil
and will render the page with Turbo disabled. If, some bright day later, you manage to get your JavaScript to a better shape on a group of pages, you can selectively switch on Turbo (and thus Turbo Drive) for them using @turbo = true
in the corresponding controllers. We are about to explore this migration path ourselves soon.
In particular, what the instructions mean is this:
The most important one is the
data-turbo="false"
attribute in the<body>
tag. It tells Turbo to ignore all links and forms on the page and leave them for standard processing by the browser. When Turbo decides whether it should handle a link click or form submit, it searches the target element and all its parents for thedata-turbo
attribute and if it finds a"false"
value, it just backs off. This tree traversal is a great feature that will later allow us to selectively switch Turbo on, see below.The other two meta tags are not strictly necessary, they serve as a kind of backup in case Turbo control ”leaks in“ somewhere unexpectedly. The
turbo-visit-control
meta tag forces Turbo to make a full page reload if it encounters an AJAX response (initiated outside of a Turbo Frame). Finally, theturbo-cache-control
meta tag ensures that the page will never be stored in Turbo’s cache.
OK, so when you browse your site now, it should behave exactly the same as you’re used to.
Using Turbo Frames
Turbo Frames act like self-replaceable blocks on a page: they capture link clicks and form submits, issue an AJAX request to the server and replace themselves with the same-named Turbo Frame extracted from the response.
Update: since Turbo version 7.2.0, explicitly enabling Turbo Frames using
data-turbo: true
is not needed any more. We can use Turbo Frames in a usual way even with Turbo (Drive) globally disabled. Note that I’m leaving the following paragraph as is for reference. The code sample works the same, either with or without thedata-turbo
attribute.
As we have Turbo globally disabled, we need to selectively enable it for each Turbo Frame, again using a data-turbo
attribute, for example:
<%# app/views/comments/show.html.erb %>
<%= turbo_frame_tag @comment, data: { turbo: true } do %>
<h2><%= @comment.title %></h2>
<p><%= @comment.content %></p>
<%= link_to "Edit", edit_comment_path(@comment) %>
<% end %>
...
<%= link_to "Homepage", root_path %>
Setting the data-turbo
attribute to "true"
will make Turbo process all links and forms inside the Turbo Frame block, while still ignoring them anywhere outside the frame. So, in our example above, the "Edit" link will be handled by Turbo (and clicking on it will render an inline edit form), whereas the "Homepage" link will still be processed normally by the browser.
Using Turbo Stream responses
Turbo Streams allow the back-end to explicitly declare changes to be made on the client. Whenever the response from the server contains one or more <turbo-stream>
elements, Turbo automatically executes the actions within them, updating the given fragments of the page.
Similarly to Turbo Frames, links or forms that expect a Turbo Stream response must be rendered in a Turbo-enabled context, so again the only change needed to make Streams work is setting the data-turbo
attribute:
<%# app/views/comments/show.html.erb %>
<div id="<%= dom_id(@comment) %>" data-turbo="true">
<%= @comment.content %>
<%= button_to "Approve", approve_comment_path(@comment) %>
</div>
If the server responds with a Turbo Stream response, e.g. via a respond_to
block, Turbo will execute the page update commands, as in this somewhat ugly example:
# app/controllers/comments_controller.rb
def approve
...
@comment.approve!
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend(dom_id(@comment),
"<p>approved!<p>")
end
end
end
Clicking on the "Approve" link will trigger Turbo (because it is enabled in that context), Turbo will make an AJAX request to the server, the server will respond with a <turbo-stream>
element containing a "prepend" action with the target of the given comment. Turbo will intercept this response and execute the action, effectively prepending the "approved!" text inside the comment div.
This is all just normal Turbo Streams handling, all we had to do above that is enable Turbo for the particular page fragment.
Using Turbo Streams broadcasting
Turbo Streams don’t even need to respond to user interactions, they can also be used for broadcasting page updates asynchronously from the back-end.
And, you know what? It just works, you don’t need to do anything special here. For a simple example, add a broadcast command to your model:
# app/models/comment.rb
class Comment < ApplicationRecord
...
after_create_commit { broadcast_prepend_to "comments" }
end
…and structure your index template accordingly and a newly created comment will be automatically prepended to a list of comments on the index page:
<%# app/views/comments/index.html.erb %>
<%= turbo_stream_from "comments" %>
<div id="comments">
<%= render @comments %>
</div>
How cool is that…?
Notes on JavaScript tags in Turbo responses
If you want to return JavaScript tags in your Turbo responses, make sure you use the Turbo 7.0.0-beta8 version or higher. This particular update fixes a bug that prevented evaluating JavaScript tags in Turbo responses.
Mind the collision with Rails UJS
If you used to render links with non-GET methods or ”AJAXified“ links with a remote: true
attribute, you need to know that these may not work any more inside Turbo-enabled contexts. These functions are handled by Rails UJS and may not be compatible with Turbo in older Rails/UJS versions. Rails UJS and Turbo should be able to coexist peacefully since Rails 7 or since jQuery UJS version 1.2.3 if you’re still using jQuery UJS.
As a safe bet, non-GET links should be converted to inline forms using button_to
and remote links should be refactored to normal links handled by Turbo.
Other UJS features, such as disabling buttons or confirm dialogs continue to work normally.
Summary
To sum this all up, Turbo seems to be perfectly usable even if your legacy JavaScript code does not allow you to switch on Turbo Drive (Turbolinks) right away. This is such a great news! Turbo enables us to gradually rewrite (and effectively remove, for the most part) our old hand-written JavaScript. We can bring modern, highly reactive behavior to our newly built and updated pages without having to refactor all that rusty JavaScript prior to that.
Once the amount of JavaScript lowers substantially, we can take care of the remaining bits and switch on Turbo Drive globally to speed up the web experience even more.
Overall we think this begins a new era in our front-end development and we are very excited about it! 💛
Would you like to read more stuff like this? Follow us on Twitter.
Posted on April 23, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.