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
- from the turbo-rails README
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 thestimulus-rails
gems have been added to the Gemfile - the
@hotwired/turbo-rails
package as well as theapp/javascript/controllers
file is imported into the mainapplication.js
- a data attribute
data-turbo-track
with the value ofreload
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.
You can disable Turbo Drive globally and enable it on a per-element basis by setting Turbo.session.drive = false
in your application.js
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:
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 %>
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.
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.
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:
- https://turbo.hotwired.dev/
- https://hotwired.dev/
- https://www.hotrails.dev/turbo-rails/turbo-frames-and-turbo-streams
- https://www.hotrails.dev/turbo-rails/turbo-streams
- https://pragprog.com/titles/nrclient2/modern-front-end-development-for-rails-second-edition/
- https://github.com/Hivekind/getting-started-with-turbo-in-rails