Engineering

Getting Started with Turbo in Rails

A diagram illustrating a Rails Application connected to various frontend code, databases, and APIs.

Introduction

The Rails 8 beta was just announced at Rails World bringing with it even more “batteries-included” features such as first-party authentication scaffolding, improvements to kamal for deployments, and a slew of Solid adapters that reduces your app’s dependency on external services such as Redis or Postgresql.

With the new release making the dream of the One Person Framework closer to reality, I thought it would be a great time to dive into the aspects of Rails that haven’t really been discussed much: Turbo, the central cog in the Hotwire ecosystem.

What is Turbo?

Turbo gives you the speed of a single-page web application without having to write JavaScript

Turbo is a set of features under the Hotwire ecosystem in Rails that enables quick navigation, real-time updates, and interactive components that behave just like Single Page Applications (SPAs) even if you don't use JavaScript frameworks such as React or Vue. It includes features like Turbo Drive (fast navigation), Turbo Frames (updating blocks of components), and Turbo Streams (enabling pin-point changes to content).

It operates with simple HTML (hence the Hotwire--HTML Over the Wire--moniker) that works even when JavaScript is disabled or unavailable. This also simplifies web development since there is no need to think separately about the client and server side of the application; the templates are rendered on the server without sacrificing any of the speed or responsiveness associated with a traditional single-page application.

Why Use Turbo?

In the early days of Rails, we rendered each page that the user requests server side. All of the html are processed, assembled, and eventually sent to the browser, and are completely generated by the Rails application.

The increasing popularity of Javascript libraries and frameworks such as React and Vue changed this dynamic. Most of the frontend code is now written with a mostly custom Javascript (either through JSX or just vanilla JS) with the Rails application relegated to functioning mostly just as an api server to retrieve persisted information from.

The emergence of SPAs (Single Page Apps) has further accelerated this trend, as websites now mimic desktop applications in that screen elements update without having the whole page reloaded. This means most of the processing and calculations that used to happen server side are now being done browser side, with information deltas being synced and persisted back to the Rails app.

Turbo allows us to emulate the responsiveness and perceived speed of SPAs without transforming the Rails application into a mere JSON API server, or writing a lot of JavaScript to get these advantages.

Setting up a new Rails 7 or 8 application with Turbo

Turbo is automatically configured for applications made with Rails 7+ via the turbo-rails gem. Applications made with Rails 6 and below can still use Turbo via the turbo-rails gem by installing it manually:

  • Add the turbo-rails gem to your Gemfile: gem 'turbo-rails'
  • Run ./bin/bundle install
  • Run ./bin/rails turbo:install We won't cover manual installation in more detail as we're only focusing on Rails 7 or 8 applications for now, but feel free to reach out to us if you need help with upgrading your Rails application :)

I was interested in what Rails adds to its configuration when enabling hotwire/turbo support, so I did the following:

  • initialized an empty git repository
  • created a stub Gemfile and bundled it
source "https://rubygems.org"

gem "rails", "8.0.0.rc1"
  • generated a new Rails 8 rc1 application with the --skip-hotwire option
~/Documents/hivekind/getting-started-with-turbo-in-rails] (main) 
tristan@wm2-fedora ❱ my-distrobox$ bundle exec rails new . --skip-hotwire
       exist  
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
.
.
.
      create  db/cable_schema.rb
       force  config/cable.yml
  • committed the code
  • deleted all the files
  • regenerated the Rails application without skipping hotwire
[~/Documents/hivekind/getting-started-with-turbo-in-rails] (main) 
tristan@wm2-fedora ❱ my-distrobox$ bundle exec rails new .
       exist  
      create  README.md
      create  Rakefile
   identical  .ruby-version
      create  config.ru
.
.
.
  Import Stimulus controllers
      append    app/javascript/application.js
  Pin Stimulus
  Appending: pin "@hotwired/stimulus", to: "stimulus.min.js"
      append    config/importmap.rb
  Appending: pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
      append    config/importmap.rb
  Pin all controllers
  Appending: pin_all_from "app/javascript/controllers", under: "controllers"
      append    config/importmap.rb
.
.
.
      create  db/cable_schema.rb
       force  config/cable.yml
  • did a git diff
[~/Documents/hivekind/getting-started-with-turbo-in-rails] (main) 
tristan@wm2-fedora ❱ my-distrobox$ git diff
diff --git a/Gemfile b/Gemfile
index 21d89f7..c7e51fa 100644
--- a/Gemfile
+++ b/Gemfile
@@ -10,6 +10,10 @@ gem "sqlite3", ">= 2.1"
 gem "puma", ">= 5.0"
 # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
 gem "importmap-rails"
+# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
+gem "turbo-rails"
+# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
+gem "stimulus-rails"
 # Build JSON APIs with ease [https://github.com/rails/jbuilder]
 gem "jbuilder"

diff --git a/Gemfile.lock b/Gemfile.lock
index 61e04d6..c1a2146 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -308,6 +308,8 @@ GEM
       net-sftp (>= 2.1.2)
       net-ssh (>= 2.8.0)
       ostruct
+    stimulus-rails (1.3.4)
+      railties (>= 6.0.0)
     stringio (3.1.1)
     thor (1.3.2)
     thruster (0.1.8)
@@ -316,6 +318,9 @@ GEM
     thruster (0.1.8-x86_64-darwin)
     thruster (0.1.8-x86_64-linux)
     timeout (0.4.1)
+    turbo-rails (2.0.11)
+      actionpack (>= 6.0.0)
+      railties (>= 6.0.0)
     tzinfo (2.0.6)
       concurrent-ruby (~> 1.0)
     unicode-display_width (2.6.0)
@@ -360,7 +365,9 @@ DEPENDENCIES
   solid_cache
   solid_queue
   sqlite3 (>= 2.1)
+  stimulus-rails
   thruster
+  turbo-rails
   tzinfo-data
   web-console

diff --git a/app/javascript/application.js b/app/javascript/application.js
index beff742..0d7b494 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1 +1,3 @@
 // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
+import "@hotwired/turbo-rails"
+import "controllers"
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 3a1c1c8..c3fed38 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -18,7 +18,7 @@
     <link rel="apple-touch-icon" href="/icon.png">

     <%# Includes all stylesheet files in app/assets/stylesheets %>
-    <%= stylesheet_link_tag :app %>
+    <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
     <%= javascript_importmap_tags %>
   </head>

diff --git a/config/importmap.rb b/config/importmap.rb
index 0086a32..909dfc5 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -1,3 +1,7 @@
 # Pin npm packages by running ./bin/importmap

 pin "application"
+pin "@hotwired/turbo-rails", to: "turbo.min.js"
+pin "@hotwired/stimulus", to: "stimulus.min.js"
+pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
+pin_all_from "app/javascript/controllers", under: "controllers"

Here's the highlights and TL;DR of this diff:

  • the turbo-rails and the stimulus-rails gems have been added to the Gemfile
  • the @hotwired/turbo-rails package as well as the app/javascript/controllers file is imported into the main application.js
  • a data attribute data-turbo-track with the value of reload has been added to the stylesheet directive
  • a number of JavaScript packages have been pinned for use with import maps

Turbo Drive: The Foundation of Turbo

Understanding Turbo Drive

Classic web applications communicating via HTTP require a full page refresh whenever a new response is received. This behavior was acceptable when the web was still relatively "slow" and users did not mind waiting for a full page refresh and repaint every time they visit a new link.

However, Single Page Applications (SPAs) have altered user expectations regarding web applications. In SPAs, the "page" is composed of various interactive components that can initiate requests independently, updating specific areas of the viewport without necessarily requiring a full page refresh. This approach creates both actual and perceived performance benefits, as it eliminates the need to redraw unnecessary elements during each request.

This is very much similar to how a native desktop application would function; it would be very disruptive if the app keeps redrawing the whole application window even if only a very tiny portion of it changes (e.g. adding a checkmark to a form field to signify that the user input was valid, or changing the status of a progress bar).

Most client-side frameworks manage this functionality by taking control over the entire application, including routing, navigation, and state management. For instance, when a user clicks on an anchor link, the framework intercepts the event and converts it into an asynchronous fetch for data, which is then merged into the application's state and re-rendered accordingly.

While this approach is effective, it also means that the application must be tailored to the specific library or framework handling routing and state management. The payloads often also become bespoke to the framework being used (e.g. some frameworks may require the payload to be JSON and in a specific schema). This can make it difficult to transition to a different library or framework (e.g., moving from React to Vue or Riot).

Turbo Drive however can grant similar benefits all with just HTML, without having to custom-fit the application to a specific library or framework. Links and form submissions are still intercepted, but rather than getting a framework to re-render the changes from a carefully managed state, Turbo Drive instead replaces the content while keeping the the browser state and stylesheets intact. With the history.pushState() api being triggered during this process, we also proper native browser back button support even if technically we did not "visit" a new page.

Such a technique gives most of the benefits of SPAs (e.g. fast repaints since the page does not technically refresh, allowing the reuse of the browser state and stylesheets) while keeping the payload as plain HTML, allowing for graceful degradation in cases where JavaScript is either unavailable (such as during edge caching) or undesirable (e.g. in situations where the user has intentionally disabled JavaScript for privacy reasons, or when the network connection is slow or unreliable).

Implementing Turbo Drive in your Application

Since we're setting up a Rails 7 or 8 application, we get Turbo Drive automatically enabled. Links and Forms (unless otherwise configured) are automatically wrapped into asynchronous fetch requests and will not trigger a full page reload.

An screen recording of a Rails application with Turbo enabled

You can disable Turbo Drive globally and enable it on a per-element basis by setting Turbo.session.drive = false in your application.js

An screen recording of a Rails application without Turbo enabled

You'll notice that when Turbo is disabled, every navigation and form submission generates a page reload.

Turbo Frames: Updating Blocks of Components

Understanding Turbo Frames

Turbo Drive concerns itself with replacing the whole body content of a page, in lieu of a page refresh. But what if we want to replace only a section of the page?

This is where Turbo Frames comes in. Turbo Frames demarcate sections of the page to be updated whenever an HTML response is received.

It functions very similarly to HTML iframes where each frame can be updated separately from the rest of the page, except that the actual frame's browsing context is the same as the rest of the page.

Unlike Turbo Streams (which we'll get to in the next section) Turbo Frames operate on requests; there needs to be an action (either a link visit or a form submission) that generates an HTTP request, which then the server will respond to and generate a document that will replace the contents of the frame. I tend to think of it more as a "pull" rather than a "push" when asked to describe the data flow.

Creating a frame is simple: you just wrap the set of elements you want to be in a frame within a turbo-frame tag. You can have as many Turbo Frames as you want, as long as each of them have its own unique ID; it is this ID that Turbo uses to figure out which content to replace with the server's response. In a way, a frame is just like a mini-page within the current page that can update independently by targetting its ID.

Implementing Turbo Frames in your Application

Let's say we have a Post with a form that allows users to add a Comment to that post.

<!-- posts/_post.html.erb -->.
<div>
  <p>
    <strong>Title:</strong>
    <%= post.title %>
  </p>

  <p>
    <strong>Body:</strong>
    <%= post.body %>
  </p>

  <%= render "comments/comments" %>

</div>
<!-- comments/_comments.html.erb -->
<% if @post.comments.length > 0 %>
  <p>
    <strong>Comments:</strong>
    <%= render @post.comments %>
  </p>
<% end %>

<%= render "comments/form", comment: @post.comments.new %>

<hr />_
# comments_controller.rb
class CommentsController < ApplicationController
  def create
    @post = Post.find(params[:comment][:post_id])
    @comment = Comment.new(params[:comment].permit(:name, :body, :post_id))

    respond_to do |format|
      if @comment.save!
        format.html { redirect_to @comment.post, notice: "Comment was successfully created." }
        format.json { render :show, status: :created, location: @comment.post }
      else
        format.html { render @comment.post, status: :unprocessable_entity }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
  end
end

Right now, when we submit a comment, Rails will re-render and replace the whole page with the newly added comments:

An screen recording of a Rails application before Turbo Frames

Just by adding a turbo frame tag with the correct id, as well as rendering the actual post template (as opposed to originally redirecting to the post controller) we can see that only the post frame was replaced, while the header and the footer persisted.

index 76c40b4..12ddb1f 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -5,7 +5,7 @@ class CommentsController < ApplicationController

     respond_to do |format|
       if @comment.save!
-        format.html { redirect_to @comment.post, notice: "Comment was successfully created." }
+        format.html { render @comment.post }
         format.json { render :show, status: :created, location: @comment.post }
       else
         format.html { render @comment.post, status: :unprocessable_entity }
diff --git a/app/views/posts/_post.html.erb b/app/views/posts/_post.html.erb
index 8eecd34..a86f4aa 100644
--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -1,3 +1,4 @@
+<%= turbo_frame_tag(dom_id(@post)) do %>
   <div>
     <p>
       <strong>Title:</strong>
@@ -12,3 +13,4 @@
     <%= render "comments/comments" %>

   </div>
+<% end %>

An screen recording of a Rails application before Turbo Frames

Just in case it wasn't clear what's happening, when you add a turbo-frame tag (via the turbo_frame_tag helper function) it indicates to Rails that this is a frame that it may need to update. When the controller returns the payload (in this case the _post.html.erb partial) that payload contains an HTML id. This id is then matched with the frame tag id, and if there's a match then only that section of the HTML in the page is replaced with the section of the HTML payload where the id is identical.

This also means we could have just rendered the entire page, or even some hard-coded HTML; as long as the id matches it will replace the identical one in the page. We don't really want to expend extra computing cycles to generate HTML for items we know aren't related to the request (e.g. the headers and the footers) so usually the payload is crafted such that it only produces the minimum necessary HTML for the update.

Turbo Streams: Enabling Pin-point Changes to Content

Understanding Turbo Streams

So while Turbo Drive can replace the whole page content, Turbo Frames can replace specific portions of the page in response to a browser request. But what if you'd want to modify just a tiny section of the page? Or maybe even modify multiple different elements at the same time? Enter Turbo Streams.

A Turbo Stream message consists of HTML fragments that are turbo-stream elements. Here's an example:

<turbo-stream action="replace" target="comment_count">
  <template>
    <sub id="comment_count">(3) comments</sub>
  </template>
</turbo-stream>

<turbo-stream action="append" target="comments">
  <template>
    <li>this is another comment</li>
  </template>
</turbo-stream>

<turbo-stream action="remove" target="comment_form">
</turbo-stream>

These are not separate payloads, but a single stream message in response to a trigger (either a user action, or possibly some other trigger such as a database update or WebSocket broadcast).

There are eight possible stream actions, all working with the element identified by the target attribute. Some of them however are variations of the same basic action that controls for certain side effects, so I'll just list down the simpler and most common ones:

  • append: will append the template
  • prepend: will prepend the template
  • replace: will replace the existing content with the template
  • remove: will remove the element (not just the content)

While Turbo (and by consequence Turbo Streams) is platform agnostic, Rails has deep integration with Turbo Streams. In order to respond with a turbo stream, you need to set the format that your controller will respond to as format.turbo_stream:

# ... rest of controller here
respond_to do |format|
  if @comment.save!
    format.html { render @comment.post }
    format.turbo_stream
  end
end

And by convention, you name the corresponding view in the usual Rails format "#{action.format}.erb" (in this case action is create and format is turbo_stream:

<%= turbo_stream.replace "comment_count" do %>
  <sub id="comment_count">(<%= @post.comments.count %>) comments</sub>
<% end %>

<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>

<%= turbo_stream.remove "comment_form" %>

Wait a minute, how does Rails know that it's supposed to render the turbo_stream action view template and behave accordingly? That's because internally, Rails will inject a special mime type that signals the server that the client is turbo-streams aware.

A screenshot of a web page with a comment section and a network tab showing an HTTP request with the mime type highlighted

You don't usually need to worry about this, but it may be useful information later on when debugging weird interactions (e.g. the web proxy server such as nginx or the edge caching layer not properly forwarding mime types).

Implementing Turbo Streams in your Application

As usual, here's a diff of the modifications I've made:

diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
index 12ddb1f..f73aa08 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -7,6 +7,7 @@ class CommentsController < ApplicationController
       if @comment.save!
         format.html { render @comment.post }
         format.json { render :show, status: :created, location: @comment.post }
+        format.turbo_stream
       else
         format.html { render @comment.post, status: :unprocessable_entity }
         format.json { render json: @comment.errors, status: :unprocessable_entity }
diff --git a/app/views/comments/_comments.html.erb b/app/views/comments/_comments.html.erb
index f7c648d..83a771b 100644
--- a/app/views/comments/_comments.html.erb
+++ b/app/views/comments/_comments.html.erb
@@ -1,7 +1,11 @@
 <% if @post.comments.length > 0 %>
   <p>
     <strong>Comments:</strong>
-    <%= render @post.comments %>
+    <ul id="comments">
+      <% @post.comments.each do |comment| %>
+        <%= render partial: "comments/comment", locals: {comment: comment} %>
+      <% end %>
+    </ul>
   </p>
 <% end %>

diff --git a/app/views/comments/_form.html.erb b/app/views/comments/_form.html.erb
index d3f3634..73e8014 100644
--- a/app/views/comments/_form.html.erb
+++ b/app/views/comments/_form.html.erb
@@ -1,4 +1,4 @@
-<%= form_with(model: comment) do |form| %>
+<%= form_with(model: comment, id: "comment_form") do |form| %>
   <% if comment.errors.any? %>
     <div style="color: red">
       <h2><%= pluralize(comment.errors.count, "error") %> prohibited this post from being saved:</h2>
diff --git a/app/views/comments/create.turbo_stream.erb b/app/views/comments/create.turbo_stream.erb
new file mode 100644
index 0000000..a0486c7
--- /dev/null
+++ b/app/views/comments/create.turbo_stream.erb
@@ -0,0 +1,7 @@
+<%= turbo_stream.replace "comment_count" do %>
+  <sub id="comment_count">(<%= @post.comments.count %>) comments</sub>
+<% end %>
+
+<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
+
+<%= turbo_stream.remove "comment_form" %>
diff --git a/app/views/posts/_post.html.erb b/app/views/posts/_post.html.erb
index a86f4aa..bd61437 100644
--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -2,7 +2,8 @@
   <div>
     <p>
       <strong>Title:</strong>
-      <%= post.title %>
+      <%= post.title %><br />
+      <sub id="comment_count"><%= "(#{@post.comments.count}) comments" %></sub>
     </p>

     <p>

First, we tell the create action in the controller that we're able to respond with a turbo stream response. We then modify the _comments.html.erb partial so we can add the id comments as a wrapping element, so that the turbo stream action append can append more elements within this container element. The _form.html.erb and _post.html.erb partials are similarly modified to have an id, so that it can also be targetted via turbo stream.

A new partial for the create action is added that replaces the element with the id comment_count with the number of comments, adds the submitted comment to the comment list, and removes the form from the page.

A screen recording of a Rails application that showcases Turbo Streams

There are a few things that can be improved however. Notice that for posts that have 0 comments, submitting a new comment won't add the comment to the page. That's because there is no element with the id comments at this point; it's only rendered when there's at least one comment available. I'll leave it to you the reader to try it out yourself; the best way to learn something is to solve what I call a Goldilocks problem: something that's not too easy that you get get bored trying to think about it, but not too hard that you give up halfway through.

Conclusion

By now, you should have noticed that working with Turbo (and Hotwire in general) isn't as big of a hurdle as it would seem; the only big leap required is to start thinking more about HTML sections rather than DOM manipulation. It might also be surprising to some that we haven't written a single line of JavaScript; and yet we've been able to create interactions that used to require extensive DOM manipulation routines and state management techniques.

Rails' Turbo (and Hotwire) is akin to similar frameworks like Laravel's Livewire, Elixir's LiveView, as well as htmx in that they all share a common goal: giving developers the ability to create web applications that are dynamic and responsive, without heavy reliance on JavaScript and instead making use more use of HTML.

Many developers have turned to such simpler development methods to combat framework fatigue. The explosion and rapid evolution of JavaScript frameworks, various different build systems (npm, pnpm, yarn, bun, etc.), long JavaScript compilation times, state management complications, data syncing difficulties with the backend, and multiple other challenges related to effectively developing two applications (a frontend and a backend). Turbo offers an alternative that reduces this complexity and can simplify a lot of these things, especially if you're a small team or even just a one person developer.

We're just scratching the surface with this post; there's more to Rails Hotwire than just Turbo. In fact the Rails team recommends using another part of the ecosystem called Stimulus.js to finish off certain interactions that can't be fully done in Turbo. Then there's Turbo native (together with Strada) that can be used for binding mobile native interactions with your app.

It offers a new perspective on web development, challenging traditional approaches to building applications.

Turbo, a part of the Hotwire ecosystem, has revolutionized the way we build web applications in Rails. By simplifying complex interactions and streamlining development workflows, Turbo empowers developers to focus on what matters most: building great user experiences.

Resources:

Need help building your product?

Reach out to us by filling out the form on our contact page. If you need an NDA, just let us know, and we’ll gladly provide one!

Top software development company Malaysia awards