March 5, 2022
•Last updated July 11, 2024
Digging into Turbo with Ruby on Rails 7
When Rails 7 made its first appearance in December of 2021 a new default component was introduced called Turbo. If you have any history with Rails you might remember the Turbolinks library which is the predecessor of Turbo.
Unlike JavaScript-driven applications, Rails applications are historically server-side rendered. While you can have more real-time interactivity, it often requires a hodgepodge of techniques to make it work. Many developers were forced to reach for React or Vue.js to compensate.
Turbo is the answer to this problem.
Turbo brings new tooling to Rails apps that make them more reactive and stateful. You can still have a server-side application that responds in real-time to requests and reduce the need for a large amount of JavaScript (the main appeal for many Rails/Ruby developers).
This happens by working with specific types of requests and dynamically rendering content on a page.
With Turbo, we get the historical benefits of Turbolinks with the additional benefits of new features like Turboframes and Turbostreams. This guide is meant to give you a first look at using Turbo inside your app and how you can leverage it without needing to rely too heavily on JavaScript.
Turbo definitions
At its core, Turbo sends HTML code over the request cycle instead of something like JSON. It's easy to confuse the different offerings of Turbo so I hope to debunk those in this guide. Let's start with some definitions.
- Turbo Drive - accelerates links and form submissions by negating the need for full page reloads.
- Turbo Frames - decompose pages into independent contexts, which scope navigation and can be lazily loaded.
- Turbo Streams - deliver page changes over WebSocket, SSE, or in response to form submissions using just HTML and a set of CRUD-like actions.
- Turbo Native - lets your majestic monolith form the center of your native iOS and Android apps, with seamless transitions between web and native sections.
For this guide, I'll be focusing on the first three in this list.
The infamous todo list
What better way to show off some technology than the infamous to-do list, am I right?
I'll create a vanilla Rails 7 app using Tailwind CSS as the CSS framework we'll leverage. This installs Tailwind CSS using a non-postcss install. If you want more control I recommend passing PostCSS independently and configuring it following the Tailwind CSS documentation.
Since this guide isn't about Tailwind CSS I'll keep things as simple as possible.
rails new turbo-todos -c tailwind
Optionally, boot up the server
bin/dev
Create the Todo model
Generating a scaffold here saves us some time but we will tweak a few things coming up.
rails g scaffold Todo title:string status:integer
Update default status in Todo migration.
Before you migrate your changes let's set a default status of 0
for the status
column
class CreateTodos < ActiveRecord::Migration[7.0]
def change
create_table :todos do |t|
t.string :title
t.integer :status, default: 0
t.timestamps
end
end
end
and THEN migrate
rails db:migrate
Set a root route
To make things easier on ourselves I'll make the todos#index action our root route in config/routes.rb
# config/routes.rb
Rails.application.routes.draw do
resources :todos
root "todos#index" # add this line
end
Visiting http://localhost:3000
should now present the todos index as advertised.
Working with Turbostreams
The default scaffold template will reload a page with every request. It works fine but it's not the most interactive way to render a to-do list so we'll leverage Turbostreams to make things a little more exciting.
Here's an updated index page with some tweaks to the default scaffold design.
<!-- app/views/todos/index.html.erb-->
<div class="mx-auto max-w-3xl mt-24">
<div class="mb-6">
<h1 class="font-bold text-4xl">Turbo Todos</h1>
</div>
<%= render "form", todo: Todo.new %>
<div id="todos" class="divide-y list-none">
<%= render @todos %>
</div>
</div>
The biggest shift here is that we render the _form.html.erb
partial inside app/views/todos
inline as opposed to over on the todos#new
or todos#edit
view templates. Having to visit those routes would be a time suck so rendering the form inline is more productive in terms of user experience.
I also updated the application layout a touch
<!-- app/views/layouts/application.html.erb-->
<!DOCTYPE html>
<html>
<head>
<title>TurboTodos</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<main class="container mx-auto px-4">
<%= yield %>
</main>
</body>
</html>
The _todo.html.erb
partial is a simple change but one that renders only the todo title
attribute.
<!-- app/views/todos/_todo.html.erb -->
<li id="<%= dom_id todo %>" class="py-3">
<%= todo.title %>
</li>
Finally, the form gets a design refresh. We remove the status
field as we'll do that part on the backend. I also added a unique id
attribute to the form. We'll be using this to target Turbo Stream updates. This lets the app know where to update DOM elements on a given page.
<!-- app/views/todos/_form.html.erb -->
<%= form_with(model: todo, id: "#{dom_id(todo)}_form") do |form| %> <% if
todo.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2>
<%= pluralize(todo.errors.count, "error") %> prohibited this todo from being
saved:
</h2>
<ul>
<% todo.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="mb-6">
<%= form.label :title, class: "sr-only" %>
<div class="relative">
<%= form.text_field :title, class: "block shadow-sm rounded-full border
border-gray-200 outline-none px-6 py-4 w-full focus:border-sky-300
focus:outline-none focus:ring-4 focus:ring-sky-50 text-lg
placeholder:text-gray-400", placeholder: "Add a new todo" %>
<div class="absolute top-0 right-0">
<%= form.submit class: "mt-px rounded-full py-4 px-5 bg-sky-500 text-white
font-medium cursor-pointer border-2 border-sky-500 hover:bg-sky-600
hover:border-sky-600" %>
</div>
</div>
</div>
<% end %>
TodosController
To target the correct ID in the view we need to give the TodosController
a clue where to look.
# app/controllers/todos_controller.rb
def create
@todo = Todo.new(todo_params)
respond_to do |format|
if @todo.save
format.turbo_stream
format.html { redirect_to todo_url(@todo), notice: "Todo was successfully created." }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
Here I added a new format
within the respond_to
block. format.turbo_stream
signals to Rails when this type of request is sent to the create
action, it needs to respond to a matching create.turbo_stream.erb
file.
You may recall a similar convention when it came to using JavaScript responses in Rails years ago. We follow a similar pattern today.
You can bypass this convention entirely bypassing your block to the format.turbo_stream
response type. You might want to render a custom partial for instance instead of creating a brand new file for only this instance.
We need to create the file inside app/views/todos
called create.turbo_stream.erb
.
touch app/views/todos/create.turbo_stream.erb
Within that file we'll add the following code:
<%= turbo_stream.prepend "todos" do %>
<%= render "todo", todo: @todo %>
<% end %>
<%= turbo_stream.replace "#{dom_id(Todo.new)}_form" do %>
<%= render "form", todo: Todo.new %>
<% end %>
This code does a couple of things:
- Adds a new todo to the top of the list when it gets created. Note the "prepend" keyword
- Replaces the existing form with a new instance of the form so we don't need to refresh the page or redirect elsewhere. Note the "replace" keyword
This makes things feel like a single-page application similar to the ones the JavaScript world is so fond of.
Here's a GIF of me adding some todos after adding this code:
What's going on here exactly?
Inside the create.turbo_stream.erb
file Rails converts the markup into HTML that gets sent "over the wire" in the request. In JavaScript-driven apps this might be JSON for comparison sake.
Because we added IDs to each element we can target specific areas of a given page and render content dynamically. Turbo Streams can target any element so long as there is an identifier on it. You can use class names all the same as IDs but it's a less common convention. Ultimately, just remember that it doesn't have to be a turbo frame you're targeting.
Editing todos
We currently don't have any tools in place in the UI for editing a given todo so let's make that a reality.
We can leverage Turbo Frames for this use case. Think of Turbo Frames as a way to scope out of a singular piece of a page you'd like to manipulate.
Inside the _todo.html.erb
partial we can render a handy turbo_frame_tag
helper and pass the dom_id
and todo
to it so it's easily found on the page.
<li id="<%= "#{dom_id(todo)}_item" %>" class="py-3">
<%= turbo_frame_tag dom_id(todo) do %>
<%= link_to todo.title, edit_todo_path(todo) %>
<% end %>
</li>
This code now makes every todo link directly to the edit action of the todos controller.
Because the link is wrapped in a turbo_frame_tag
, Turbo will expect the server to return a Turbo Frame with a corresponding id. This means we need to update our edit.html.erb
file to wrap the contents in another turbo_frame_tag
. This will target the turbo_frame_tag within the _todo.html.erb
partial and render the contents of the edit.html.erb
view template. This sounds complicated but it's not once you see it work correctly.
I should also note that I needed to update the default dom_id(todo)
on the li
element because It's being used again on the turbo frame tag.
<!-- app/views/todos/edit.html.erb-->
<%= turbo_frame_tag dom_id(@todo) do %> <%= render "form", todo: @todo %> <% end
%>
The edit.html.erb
template becomes quite simple and renders our todo form with a turbo_frame_tag surrounding it.
Now we can edit our todos directly inline! Notice that Rails automagically attributes updating a todo as a turbostream response given we wrapped the content in the turbo_frame_tag as well. This means we can bypass the creation of an update.turbo_stream.erb
file and follow a similar protocol as we did with the create.turbo_stream.erb
file.
Below are the logs of the PATCH request after editing a to-do on my local app.
Started PATCH "/todos/4" for ::1 at 2022-03-02 12:20:40 -0600
12:20:40 web.1 | Processing by TodosController#update as TURBO_STREAM
12:20:40 web.1 | Parameters: {"authenticity_token"=>"[FILTERED]", "todo"=>{"title"=>" Learn Turbo!"}, "commit"=>"Update Todo", "id"=>"4"}
Pretty sweet!
Accounting for validations
You may want to add some validations to fields in a form. We can add these to the model layer. In our simple case, I'll add a presence validation just to make sure a todo exists before creating an empty record in the database.
# app/models/todo.rb
class Todo < ApplicationRecord
validates :title, presence: true
end
Unfortunately, if you try to submit the form with an empty todo title the app renders the new.html.erb
template which isn't ideal. We can fix this by updating what might render in place by passing an explicit partial in a block for the format.turbo_stream
response type.
I updated the todos_controller.rb
create
action to look like the following.
# app/controllers/todos_controller.rb
def create
@todo = Todo.new(todo_params)
respond_to do |format|
if @todo.save
format.turbo_stream
format.html { redirect_to todo_url(@todo), notice: "Todo was successfully created." }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("#{helpers.dom_id(@todo)}_form", partial: "form", locals: { todo: @todo }) }
format.html { render :new, status: :unprocessable_entity }
end
end
end
After the else
statement, we now render another format.turbo_stream
response and pass more options in a block. Here we are targeting a form by its id and calling turbo_stream.replace
to replace its contents with the form partial inside app/views/todos
. This means our app won't redirect to the new action anymore if a todo can't be saved and our errors will render all inline.
For good measure, I'll update the todos#update action similarly.
# app/controllers/todos_controller.rb
def update
respond_to do |format|
if @todo.update(todo_params)
format.html { redirect_to todo_url(@todo), notice: "Todo was successfully updated." }
else
format.turbo_stream { render turbo_stream: turbo_stream.replace("#{helpers.dom_id(@todo)}_form", partial: "form", locals: { todo: @todo }) }
format.html { render :edit, status: :unprocessable_entity }
end
end
end
Deleting Todos
Following a similar protocol as creating todos, we can invoke the feature of deleting them. I'll update the _todo.html.erb
partial once more to include a delete button.
<li id="<%= "#{dom_id(todo)}_item" %>" class="py-3">
<%= turbo_frame_tag dom_id(todo) do %>
<div class="flex items-center justify-between">
<%= link_to todo.title, edit_todo_path(todo) %>
<%= button_to "Delete", todo_path(todo), method: :delete, class: "bg-red-50 px-3 py-2 rounded inline-flex items-center justify-center text-red-600" %>
</div>
<% end %>
</li>
We'll use a button_to
helper which essentially renders a form in place. Note the addition of the method: :delete
attributes. These tell Rails I'm looking for this form to respond to a DELETE
request rather than a typical POST
or GET
.
A DELETE
request means our controller will target the destroy
action given the CRUD/RESTful conventions built into the framework.
# app/controllers/todos_controller.rb
def destroy
@todo.destroy
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove("#{helpers.dom_id(@todo)}_item") }
format.html { redirect_to todos_url, notice: "Todo was successfully destroyed." }
end
end
Following the pattern of turbo_stream
response formats, I rendered a turbo_stream response that calls turbo_stream.remove
and targets the associated li
tag or todo directly.
Clicking the "Delete" button now removes it for good!
Completing a Todo
What's a to-do list if you can mark items off as complete?
If you recall at the beginning of this guide we had a status
column on the Todo
table/model. This was an integer which is helpful when working with enums
. We can set new statuses in the Todo
model so the app knows how to respond.
Small aside: I published a guide on How to use enums in Ruby on Rails recently you should check out if you're new to them.
To start, let's define some statuses for the Todo model
class Todo < ApplicationRecord
validates :title, presence: true
enum status: { incomplete: 0, complete: 1 }
end
With these defined, we get some build-in methods to check on a given status. I'll update the _todo.html.erb
partial once more to account for each status.
<!-- app/views/todos/_todo.html.erb -->
<li id="<%= "#{dom_id(todo)}_item" %>" class="py-3">
<%= turbo_frame_tag dom_id(todo) do %>
<div class="flex items-center justify-between">
<%= link_to todo.title, edit_todo_path(todo), class: "flex-1 #{"line-through opacity-50" if todo.complete?}" %>
<div class="flex items-center space-x-3">
<% if todo.complete? %>
<%= button_to "Mark incomplete", todo_path(todo, todo: { status: 'incomplete'}), method: :patch, class: "bg-neutral-50 px-3 py-2 rounded inline-flex items-center justify-center text-neutral-600" %>
<% else %>
<%= button_to "Mark complete", todo_path(todo, todo: { status: 'complete'}), method: :patch, class: "bg-green-50 px-3 py-2 rounded inline-flex items-center justify-center text-green-600" %>
<% end %>
<%= button_to "Delete", todo_path(todo), method: :delete, class: "bg-red-50 px-3 py-2 rounded inline-flex items-center justify-center text-red-600" %>
</div>
</div>
<% end %>
</li>
A few things are going on here.
The todo edit link now has conditionally styling applied if it's marked complete.
<%= link_to todo.title, edit_todo_path(todo), class: "flex-1 #{"line-through
opacity-50" if todo.complete?}" %>
We conditionally check the status and render appropriate button_to view helpers. Each button_to element is set up to be a PATCH
request which will essentially update the todo
passed to it.
You can pass params formatted in such a way as to omit the need to do much logic in the controller. It's more of a preference if you'd rather handle it in the view or controller layer.
<% if todo.complete? %>
<%= button_to "Mark incomplete", todo_path(todo, todo: {
status: 'incomplete'}), method: :patch, class: "bg-neutral-50 px-3 py-2 rounded
inline-flex items-center justify-center text-neutral-600" %>
<% else %>
<%= button_to "Mark complete", todo_path(todo, todo: { status: 'complete'}), method:
:patch, class: "bg-green-50 px-3 py-2 rounded inline-flex items-center
justify-center text-green-600" %>
<% end %>
Rendering completed items last
As a UX improvement, it would be great to render any complete todos at the end of the list. This should be a simple change but unfortunately, the enum helpers don't provide something suitable. We'll need to tweak the todos#index query a bit to compensate.
# app/controllers/todos_controller.rb
class TodosController < ApplicationController
def index
@todos = Todo.in_order_of(:status, %w[incomplete complete])
end
end
Adding this query does the trick but you might notice as you mark various todos complete the list doesn't update in real-time. We can try to get around this but it will take some changes that I must say in advance aren't perfect.
I added the following just before our todo list rendering in app/views/todos/index.html.erb
<!-- app/views/todos/index.html.erb-->
<%= turbo_stream_from "todos" %>
<div id="todos" class="divide-y list-none">
<%= render @todos %>
</div>
The turbo_stream_from "todos"
line adds a layer propped up by Action Cable behind the scenes. To get a more real-time sorting capability we also need to add a callback function to the Todo
model.
# app/models/todo.rb
class Todo < ApplicationRecord
after_update_commit { broadcast_append_to 'todos' }
#...
end
This broadcasts the update to our index view once we mark a todo complete or incomplete. This isn't perfect because if you mark a to-do complete and then incomplete again the order is disrupted but I ran out of time to debug. If you have any ideas on how to better enhance this I'm all ears!
For the most part, this works as advertised in terms of an "exercise" this was a good one to hopefully show you the power Turbo has to offer. Seeing things update and change in real-time with Rails without writing JavaScript is pretty huge. I'm excited to keep exploring more ways to make use of this new tech.
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.