DEV Community

Cover image for Using Hotwire Turbo in Rails with legacy JavaScript

Using Hotwire Turbo in Rails with legacy JavaScript

borama profile image Matouš Borák ・10 min read

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.

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 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
Enter fullscreen mode Exit fullscreen mode

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.

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 %>
    <% unless @turbo %>
      <meta name="turbo-visit-control" content="reload" />
      <meta name="turbo-cache-control" content="no-cache" />
    <% end %>
  <body data-turbo="<%= @turbo.present? %>">
Enter fullscreen mode Exit fullscreen mode

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 the data-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, the turbo-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.

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 %>
Enter fullscreen mode Exit fullscreen mode

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 Streams 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) %>
Enter fullscreen mode Exit fullscreen mode

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

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.prepend(dom_id(@comment),
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

…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 %>
Enter fullscreen mode Exit fullscreen mode

How cool is that…?

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 won’t work any more inside Turbo-enabled contexts. These functions are handled by Rails UJS and are not compatible with Turbo. 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.


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.

Discussion (6)

Editor guide
leastbad profile image

I don't understand or agree with the push to remove JS from the picture. Why artificially limit yourself?

The sweet spot of #ReactiveRails - regardless of whether you use StimulusReflex or stick with Turbo - is to leverage Stimulus controllers that work with your reactive updates.

It is only through the use of clever JS behaviours that event-driven SSR reaches full potential.

borama profile image
Matouš Borák Author

I don't think we are in disagreement. I don't want to remove all JS, I just want to get rid of JS needed for basic reactive behavior (like showing, hiding, lazy loading parts of page, etc) and for the remaining something like ~20% (?) interactions, Stimulus is very welcome.

leastbad profile image

Yes, we agree. Phew! ;)

Seriously, though: where does the Ruby community's near-obsession with writing as little JS as possible come from? It's not a good look for a community that used to pride itself on how we were all polyglots.

Thread Thread
borama profile image
Matouš Borák Author

Yeah, I see what you mean. If I speak for myself, it's often just too much for me to grasp both languages deeply, the pace of JS progress is too big. It seems to me that JS used to be way simpler. And perhaps it's also just me growing older 🙂.

Thread Thread
iamnan profile image
Dave Gerton

One of the characteristics of ruby is that a few lines can encapsulate a lot of behavior. Core motivations like reflection and DRYness promotes this. It's not that rubyists (rails devs, more accurately) are avoiding JS specifically, they/we/I like to write as little any-language code as possible. It's definitely not something non-rubyists appreciate as much - I see referring to rail+pg+puma as the "magic stack" as an example - but if we measure success in time-to-market and maintainability, the fewer LOCs the better.

With regards to "not a good look," the ruby community has not been doing a very good marketing job and I think this is why Javascript and Python have overtaken it, not because we needed leftward assignment etc. But that's another topic.

Thread Thread
leastbad profile image

You raise some good points, but you're not actually addressing mine, friend.

For example, I guarantee you that smart use of a Stimulus controller paired with your server-side updates will result in a lower LOC and a slicker end result. The values API and the MutationObserver in general are OP, to borrow a term from gamer-speak.

It's absolutely true that Ruby Central has radically failed compared to the Python Foundation when it comes to advocacy and marketing. RC seems to limit itself to running conferences, whereas PF sees itself rightly as a lobbyist - and I believe that this is precisely why, 15 years later, Python has such a huge lead in ML and data science, for example.

But you know what counts as bad marketing? Eschewing our "we are language polyglots" banner in favour of JavaScript bigotry. It's enough to make me wonder if Giles Bowkett wasn't on to something when he speculated that the Rails community's attitude toward JS might be rooted in subtle sexism.

It's a long post but it's highly worth revisiting. It's so strange to consider that it was written in 2016. Everything and nothing is different.