Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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:

  1. Adds a new todo to the top of the list when it gets created. Note the "prepend" keyword
  2. 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:

Todo List preview

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.

Link this article
Est. reading time: 15 minutes
Stats: 22,015 views

Categories

Collection

Part of the Hotwire and Rails collection

Products and courses