Using Hotwire Turbo in Rails with legacy JavaScript
Tags: rails, javascript, hotwire, turbo
Published: 2021-04-23 12:49:53

When [Hotwire Turbo](https://turbo.hotwired.dev/) 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](https://twitter.com/dhh/status/1266057500547141633). 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](https://github.com/rails/prototype-rails) library since who knows when but it was [phased out](https://github.com/rails/prototype_legacy_helper) gradually (~2010),
- and in Rails 3.1, it was [replaced](https://guides.rubyonrails.org/3_1_release_notes.html#default-js-library-is-now-jquery) by [jQuery](https://github.com/rails/jquery-rails) (~2011),
- Rails 3.1 also brought [CoffeeScript](https://github.com/rails/ruby-coffee-script) as a new and encouraged way of ”writing JavaScript“ (~2011),
- there was [Unobtrusive JavaScript](https://guides.rubyonrails.org/working_with_javascript_in_rails.html#unobtrusive-javascript) to replace the inline style; it was pushed further by the [jquery-ujs library](https://github.com/rails/jquery-ujs) (~2010), later superseded by the [somewhat compatible](https://dev.to/nejremeslnici/migrating-from-jquery-ujs-to-rails-ujs-k9m) [Rails UJS](https://github.com/rails/rails/tree/main/actionview/app/assets/javascripts) (2016),
- there were [Server-generated JavaScript Responses](https://signalvnoise.com/posts/3697-server-generated-javascript-responses) (SJR) allowing the server to update pages via JavaScript (~2011),
- since Rails 4, the [Turbolinks library](https://github.com/turbolinks/turbolinks) has been included but had a [bunch of problems](https://youtu.be/ktZLpjCanvg?t=2898) 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](https://github.com/turbolinks/turbolinks-classic) (2016),
- Rails 5.1 [optionally adopted](https://github.com/rails/webpacker) the [webpack bundler](https://webpack.js.org/) and the [yarn package manager](https://yarnpkg.com/) (2017), the two became the preferred way of handling JavaScript in Rails,
- Rails 5.1 also [removed jQuery](https://github.com/rails/rails/pull/27113) from default dependencies (2017)
- the [Stimulus JS](https://m.signalvnoise.com/stimulus-1-0--a-modest-javascript-framework-for-the-html-you-already-have/) framework was released (2018),
- CoffeeScript, although still soft-supported via [a gem](https://github.com/rails/ruby-coffee-script), is [discouraged](https://github.com/rails/ruby-coffee-script/issues/22#issuecomment-355821741) in favor of vanilla ES6 JavaScript or Typescript compiled via webpack (~2018),
- after being in beta for 3 years, [Sprockets 4](https://github.com/rails/sprockets#upgrading-to-sprockets-4x) was released, with support for ES6 and source maps in the asset pipeline (2019), to serve people still hesitant with webpack,
- and finally [Turbo](https://turbo.hotwired.dev/) which should become a part of Rails 7 (late 2020),
- oh and by the way, DHH [nowadays explores](https://twitter.com/dhh/status/1334428951180173313) 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](https://github.com/rails/rails/pull/42999) 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](https://twitter.com/boramacz/status/1149590543485173760), 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](https://turbo.hotwired.dev/)** 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](https://stimulus.hotwired.dev/) 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](https://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](https://turbo.hotwired.dev/handbook/drive), the successor of Turbolinks, is a member of the Turbo family. This library is cool but **[requires](https://turbo.hotwired.dev/handbook/building) 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](https://turbo.hotwired.dev/handbook/installing) 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](https://github.com/rails/sprockets/blob/master/UPGRADING.md)). You also need a recent-enough Rails version (Rails 6, it [seems](https://github.com/hotwired/turbo-rails/blob/main/turbo-rails.gemspec#L13)). 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](https://github.com/hotwired/turbo-rails). 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:

```ruby
# 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](https://github.com/hotwired/turbo-rails/commit/cf4ec3fae8eb05aac8785c0c70804c15fc430995) 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:

```js
// 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:

```erb
<%# 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](https://turbo.hotwired.dev/handbook/drive#disabling-turbo-drive-on-specific-links-or-forms) 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](https://github.com/hotwired/turbo/blob/22cbc4fca58ecf07e82d8a66e3afdd632dc45ab8/src/core/session.ts#L262) 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](https://turbo.hotwired.dev/handbook/drive#ensuring-specific-pages-trigger-a-full-reload) 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](https://turbo.hotwired.dev/handbook/building#opting-out-of-caching) 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](https://turbo.hotwired.dev/handbook/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](https://github.com/hotwired/turbo/releases/tag/v7.2.0), explicitly enabling Turbo Frames using `data-turbo: true` is **[not needed](https://github.com/hotwired/turbo/pull/648) 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 the `data-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:

```erb
<%# 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](https://turbo.hotwired.dev/handbook/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:

```erb
<%# 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:

```ruby
# 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:

```ruby
# 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:

```erb
<%# 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](https://github.com/hotwired/turbo/releases/tag/v7.0.0-beta.8) version or higher. This particular update [fixes a bug](https://github.com/hotwired/turbo/pull/192) 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](https://github.com/rails/rails/tree/main/actionview/app/assets/javascripts) and may [not be compatible with Turbo](https://github.com/hotwired/turbo-rails/blob/82f5e002863fea0a5605a3a48b4c6501c46da7f8/README.md#compatibility-with-rails-ujs) in older Rails/UJS versions. Rails UJS and Turbo [should be able to coexist](https://github.com/hotwired/turbo-rails/blob/main/UPGRADING.md) peacefully [since Rails 7](https://github.com/rails/rails/pull/42476) or since [jQuery UJS version 1.2.3](https://github.com/rails/jquery-ujs/pull/521) if you’re [still using jQuery UJS](https://dev.to/nejremeslnici/migrating-from-jquery-ujs-to-rails-ujs-k9m).

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](https://twitter.com/boramacz) us on Twitter._