Turbo Rails 101: Building a todo app with Turbo

When Rails 7 released in December, Turbo became a default component of all new Ruby on Rails applications. This was an exciting moment for Rails developers that want to use Rails full-stack — especially folks on small teams with limited resources to pour into building and maintaining a React or Vue frontend.

Along with that excitement has come a constant stream of developers trying to learn how Turbo works, and how to integrate it into their Rails applications successfully.

For many, that learning journey has included a lot of roadblocks — Turbo requires some changes to how you structure applications and the official documentation is not yet as detailed as it could be. The good news is, most folks hit the same roadblocks early on their journey, which means we can help folks faster by addressing the common points of confusion.

In particular, there is confusion about how to use Turbo Frames and Turbo Streams together, and confusion about how Turbo Streams work.

Today, we are going to build a simple todo list application, powered entirely by Turbo. While building, we will take a few detours to look more deeply at a few common Turbo behaviors, and we will directly address two of the most common misconceptions that I see from folks who are brand new to using Turbo in their Rails applications.

When we are finished, our application will work like this:

A screen recording of a user of a web application adding and removing todo items from a list.

This tutorial is written for Rails developers who are brand new to Turbo. The content is very much Turbo 101 and may not be useful if you are already comfortable working with Turbo Frames and Turbo Streams. This article also assumes comfort with writing standard CRUD applications in Rails — if you are have never used Rails before, this is not the right place to start!

As usual, you can find the complete code for our demo application on Github.

Let’s start building!

Application setup

We will start with a brand new Rails 7 application which comes with Turbo out of the box.

Generate a new Rails application with Tailwind CSS for styling. From your terminal:

rails new turbo-todo --css=tailwind
cd turbo-todo

And then scaffold up a Todo resource. From your terminal again:

rails g scaffold Todo name:string status:integer

Update migration to set default value for status:

class CreateTodos < ActiveRecord::Migration[7.0]
  def change
    create_table :todos do |t|
      t.string :name
      t.integer :status, default: 0

      t.timestamps
    end
  end
end

Finally, create and migrate the database:

rails db:create db:migrate

Create new todos with Turbo Streams

The Rails scaffold generator provides a fully functional implementation of todos out of the box. If you start up the Rails app and head to /todos you can create, edit, and delete todos to your heart’s content, but every request will initiate a full page turn. Not very exciting.

Our goal in this section is to update the existing todo scaffold to use Turbo Streams to insert newly created todos into the DOM without a full page turn or any custom JavaScript.

Start by replacing the content of the index view, app/views/todos/index.html.erb, with the following:

<div class="mx-auto w-1/2">
  <h2 class="text-2xl text-gray-900">
    Your todos
  </h2>
  <div class="w-full max-w-2xl bg-gray-100 py-8 px-4 border border-gray-200 rounded shadow-sm">
    <div class="py-2 px-4">
      <%= render "form", todo: Todo.new %>
    </div>
    <ul id="todos">
      <%= render @todos %>
    </ul>
  </div>
</div>

Here, we updated the page layout so it looks a little nicer and inserted the new todo form directly on to the page. Users will use this form to add new todos, and existing todos will be rendered in the <ul> below the form.

Note that the <ul> has an id of todos. Turbo Streams target elements in the DOM by id, and the todos id will be used to insert newly created todos into the DOM.

Update app/views/todos/_todo.html.erb to render each todo properly inside of the <ul>:

<li class="py-2 px-4 border-b border-gray-300">
  <%= todo.name %>
</li>

The form partial we render in the index view needs a few adjustments too. In 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="flex items-stretch flex-grow">
    <%= form.label :name, class: "sr-only" %>
    <%= form.text_field :name, class: "block w-full rounded-none rounded-l-md sm:text-sm border-gray-300", placeholder: "Add a new todo..." %>
    <%= form.submit class: "-ml-px relative px-4 py-2 border border-blue-600 text-sm font-medium rounded-r-md text-white bg-blue-600 hover:bg-blue-700" %>
  </div>
<% end %>

Note the addition of an id to the <form>, using the dom_id of the todo passed to the partial, which will be used to target Turbo Stream updates.

To use these new ids to update the DOM, we need to tell our controller to render a Turbo Stream when the form is submitted.

To do this, head to the TodosController and update the create action:

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

The change here is the addition of format.turbo_stream to the happy path in the action. format.turbo_stream tells Rails that when a Turbo Stream request is sent to the create action, respond with a matching create.turbo_stream.erb file.

If you are a long-time Rails developer, this will feel very similar to responding with js.erb files in response to ajax requests.

In order for this to work, we need to create the create.turbo_stream.erb file, otherwise you will get an error about a missing template.

From your terminal:

touch app/views/todos/create.turbo_stream.erb

And then fill that new file in:

<%= 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 %>

Our first look at a Turbo Stream! In create.turbo_stream.erb we render two <turbo-stream> elements.

The first prepends the newly created todo to the list of todos, targeting the <ul> with the id of todos. The second replaces the todo form with a fresh copy of the form, allowing us to clear the todo form after each successful submission.

At this point, you can start up your Rails application with bin/dev. Head to localhost:3000/todos, create a couple of todos and see that they automatically append to the list of todos. Magic.

A screen recording of a user of a web application adding a todo item to the list of todos

Let’s pause here and review what is happening in a little more detail. Each time the user submits the new todo form, the request sent to the server includes an Accept header that identifies the request as a Turbo Stream request:

text/vnd.turbo-stream.html, text/html, application/xhtml+xml

Turbo sets this header automatically on all POST, PUT, PATCH, DELETE form submissions, with no intervention required from the developer. As of Turbo 7.2, you can optionally respond Turbo Streams in response to GET requests. Just include data-turbo-stream on the link or GET form you want to process as a Turbo Stream.

turbo-rails registers a turbo_stream Mime type to enable responding to inbound Turbo Stream form submissions with turbo_stream content. We see this in action in the TodosController:

def create
  respond_to do |format|
    format.turbo_stream
  end
end

When calling format.turbo_stream without passing a block, Rails conventions expect that a file that matches the action and Mime type exists — in our case, create.turbo_stream.erb.

In create.turbo_stream.erb, we render <turbo-stream> elements using the turbo_stream helper. Rails renders the create.turbo_stream view to HTML and sends that HTML back to the frontend:

<turbo-stream action="prepend" target="todos">
  <template>
    <li class="py-2 px-4 border-b border-gray-300">
      A new todo
    </li>
  </template>
</turbo-stream>

<turbo-stream action="replace" target="new_todo_form">
  <template>
    <form id="new_todo_form" action="/todos" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="TmpPYfxQOln1t3jmigbzZ49ciBWjivGEjp_nJUWJMeUJZSoeA8dvbkLeD6kLkmHZx-zc8kOzcqu69tWGYASlpQ" autocomplete="off" />
      <div class="flex items-stretch flex-grow">
        <label class="sr-only" for="todo_name">Name</label>
        <input class="block w-full rounded-none rounded-l-md sm:text-sm border-gray-300" placeholder="Add a new todo..." type="text" name="todo[name]" id="todo_name" />
        <input type="submit" name="commit" value="Create Todo" class="-ml-px relative px-4 py-2 border border-blue-600 text-sm font-medium rounded-r-md text-white bg-blue-600 hover:bg-blue-700" data-disable-with="Create Todo" />
      </div>
    </form>
  </template>
</turbo-stream>

Turbo extracts the Turbo Stream elements from the HTML and uses each element’s action and content to update the DOM.

Now we understand a bit about what is happening when our Turbo-powered from is submitted, which also gives us the knowledge to knock out a few common misconceptions about Turbo.

Turbo Streams can target any element, not just Turbo Frames

First, many new Turbo developers think that Turbo Streams can only target Turbo Frames. This misconception causes them to run into issues nesting their forms within an unnecessary Turbo Frame or to end up with invalid HTML by wrapping <tr> or <li> elements in <turbo-frame> elements.

Issues caused by this misconception come up almost daily on the Rails Internet and Turbo Streams can be very difficult to work with while laboring under this misconception. Although the documentation never links Turbo Frames and Turbo Streams in this way, the issue persists.

So, let’s get very clear here: Turbo Streams target elements in the DOM by id (or class, less commonly). Any element with an id can be targeted by a Turbo Stream, not just Turbo Frame elements.

Turbo Streams do not require WebSockets

Turbo Streams have gotten a lot of attention because they can be used with WebSockets to proactively send updates to many users at once outside of the standard request/response cycle.

With turbo-rails, developers can easily broadcast updates from models and background jobs to send <turbo-stream> snippets over WebSockets with ActionCable.

These types of WebSockets-powered Turbo Stream broadcasts are great — but as you saw in the TodosController, you can also just render Turbo Stream tags in response to a request from a browser. You can make great use of Turbo Streams without WebSockets.

Now that we have gotten way down into the weeds of Turbo Streams, let’s zoom back out a bit and take our first look at Turbo Frames.

Editing existing todos

Users will edit their todos by clicking on the name of the todo. When they click on the name, the edit form for that todo will render in place of the todo in the list, like this:

A screen recording of a user of a web application clicking a todo in a list. An edit form replaces the todo item in the list.

To build this functionality, we will use a Turbo Frame to scope navigation to the piece of the page we want to update. Start by updating the existing todo partial like this:

<li id="<%= "#{dom_id(todo)}_container" %>" class="py-2 px-4 border-b border-gray-300">
  <%= turbo_frame_tag dom_id(todo) do %>
    <%= link_to todo.name, edit_todo_path(todo) %>
  <% end %>
</li>

Here, we added a unique id to each <li>. Nested within the <li>, we added a <turbo-frame> using the turbo-rails provided turbo_frame_tag helper method.

Within the Turbo Frame is a link_to pointing to the edit action in the TodosController. Because the link is within the Turbo Frame, Turbo will expect the server to return a Turbo Frame with a matching id. Turbo will extract the matching Turbo Frame from the response HTML and use it to replace the original content of the frame.

In our case, that means when the user clicks on the todo’s name, edit.html.erb will render an edit form and that form will replace the link to the edit page.

Let’s see this in action. Update app/views/edit.html.erb:

<%= turbo_frame_tag dom_id(@todo) do %>
  <%= render "form", todo: @todo %>
<% end %>

The turbo_frame_tag has an id that matches the turbo_frame_tag in the todo partial.

With that change in place, refresh the todos index page and click on the name of a todo. If all has gone well, you will see that the edit form replaces that todo in the list. If you submit the form, you will see that you get redirected to the show page of the todo you edited — not quite there yet!

We will fix this issue with another Turbo Stream rendered from the server, this time for TodosController#update.

From your terminal:

touch app/views/todos/update.turbo_stream.erb

Update the new update.turbo_stream.erb view like this:

<%= turbo_stream.replace "#{dom_id(@todo)}_container" do %>
  <%= render "todo", todo: @todo %>
<% end %>

We are using a Turbo Stream replace action again. This time the Turbo Stream action replaces the content of the <li> wrapping the todo with the content of the todo partial. Because the edit form is replaced by the updated Todo, we do not need to reset the edit form like we did the new form in create.turbo_stream.erb.

Update the TodosController to respond to Turbo Stream requests:

def update
  respond_to do |format|
    if @todo.update(todo_params)
      format.turbo_stream
      format.html { redirect_to todo_url(@todo), notice: "Todo was successfully updated." }
      format.json { render :show, status: :ok, location: @todo }
    else
      format.html { render :edit, status: :unprocessable_entity }
      format.json { render json: @todo.errors, status: :unprocessable_entity }
    end
  end
end

Now when the edit form is submitted successfully the edit form is replaced with an updated version of the edited todo’s partial.

At this point, we can create and edit todos without a full page turn but you may have noticed that we are not using Turbo Streams to handle invalid form submissions. The else path in the create and update actions is missing a turbo_stream response. We will fix that in the next section.

Handling form errors

To demonstrate handling form errors, we need to first add a validation to the Todo model so that we can send invalid form submissions to the server. In app/models/todo.rb:

validates_presence_of :name

Form submissions with a blank name will fail to save, letting us test out error handling with Turbo Streams.

Head back to the TodosController and update the create and update actions:

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

def update
  respond_to do |format|
    if @todo.update(todo_params)
      format.turbo_stream
      format.html { redirect_to todo_url(@todo), notice: "Todo was successfully updated." }
      format.json { render :show, status: :ok, location: @todo }
    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 }
      format.json { render json: @todo.errors, status: :unprocessable_entity }
    end
  end
end

When the todo fails to save, we render a turbo_stream directly from the controller, replacing the content of the form with an updated version of the form so that errors are displayed to the user.

This method of rendering Turbo Streams inline in the controller is an alternative to creating views like create.turbo_stream.erb — either approach will work. In practice, it tends to be easier to manage complex Turbo Stream responses with dedicated views while rendering simple responses inline works fine for single stream responses.

Deleting todos

Next up, we will add the ability to delete todos without a page turn by using another Turbo Stream rendered from the controller.

Start by updating the todo partial to add a delete button:

<li id="<%= "#{dom_id(todo)}_container" %>" class="py-2 px-4 border-b border-gray-300">
  <%= turbo_frame_tag dom_id(todo) do %>
    <div class="flex justify-between items-center space-x-2">
      <%= link_to todo.name, edit_todo_path(todo) %>

      <%= button_to todo_path(todo), class: "bg-red-600 px-4 py-2 rounded hover:bg-red-700", method: :delete do %>
        <span class="sr-only">Delete</span>
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor" title="Delete todo">
          <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
        </svg>
      <% end %>
    </div>
  <% end %>
</li>

Note the method: :delete on the button, which ensures the button hits the destroy action on the controller. The svg icon here is from Heroicons — feel free to just make the button say “Delete” if you like, that will work fine too.

In the TodosController, update the destroy action:

def destroy
  @todo.destroy

  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.remove("#{helpers.dom_id(@todo)}_container") }
    format.html { redirect_to todos_url, notice: "Todo was successfully destroyed." }
    format.json { head :no_content }
  end
end

This inline turbo_stream uses the Turbo Stream remove action to remove the target element from the DOM entirely.

Refresh the index page, click the delete button on a todo and see that the todo is removed from the DOM without a full page turn.

Mark todos as complete

A todo list is not very helpful if todos cannot be marked as complete. In this section, we will add a button to toggle todos complete and incomplete, relying as usual on Turbo Streams to update the DOM for us.

To begin, let’s define a simple status enum in the Todo model:

enum status: {
  incomplete: 0,
  complete: 1
}

Back to app/views/todos/_todo.html.erb:

<li id="<%= "#{dom_id(todo)}_container" %>" class="py-2 px-4 border-b border-gray-300">
  <%= turbo_frame_tag dom_id(todo) do %>
    <div class="flex justify-between items-center space-x-2">
      <%= link_to todo.name, edit_todo_path(todo), class: todo.complete? ? 'line-through' : '' %>

      <div class="flex justify-end space-x-3">
        <% if todo.complete? %>
          <%= button_to todo_path(todo, todo: { status: 'incomplete' }), class: "bg-green-600 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
            <span class="sr-only">Mark as incomplete</span>
            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
            </svg>
          <% end %>
        <% else %>
          <%= button_to todo_path(todo, todo: { status: 'complete' }), class: "bg-gray-400 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
            <span class="sr-only">Mark as complete</span>
            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
            </svg>
          <% end %>
        <% end %>

        <%= button_to todo_path(todo), class: "bg-red-600 px-4 py-2 rounded hover:bg-red-700", method: :delete do %>
          <span class="sr-only">Delete</span>
          <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor" title="Delete todo">
            <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
          </svg>
        <% end %>
      </div>
    </div>
  <% end %>
</li>

There’s a lot here, let’s cut through the noise to highlight the important functional pieces.

The todo edit link gets struck through when the todo is complete:

<%= link_to todo.name, edit_todo_path(todo), class: todo.complete? ? 'line-through' : '' %>

If the todo is complete, we render button_to to mark the todo as incomplete. Incomplete todos get a button_to to mark the todo as complete.

<% if todo.complete? %>
  <%= button_to todo_path(todo, todo: { status: 'incomplete' }), class: "bg-green-600 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
<% else %>
  <%= button_to todo_path(todo, todo: { status: 'complete' }), class: "bg-gray-400 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
<% end %>

In either case the patch request goes to TodosController#update as a Turbo Stream request, and the existing update.turbo_stream.erb view is rendered.

If this were a real application, we would pull these buttons out into helper methods or into a view component, but for our purposes, we can live with a messy partial.

Complete/incomplete todos in separate tabs

Now that users can mark todos as complete, it would be nice to not have to see completed todos all of the time. We will finish up our Turbo-powered todo application by adding a tabbed interface to the todos index page, allowing users to toggle between incomplete and complete todos.

Get started by adding simple filtering logic to the index action in the TodosController:

def index
  @todos = Todo.where(status: params[:status].presence || 'incomplete')
end

Then update app/views/todos/index.html.erb:

<div class="mx-auto w-1/2">
  <h2 class="text-2xl text-gray-900">
    Your todos
  </h2>
  <%= turbo_frame_tag "todos-container", class: "block max-w-2xl w-full bg-gray-100 py-8 px-4 border border-gray-200 rounded shadow-sm" do %>
    <div class="border-b border-gray-200 w-full">
      <ul class="flex space-x-2 justify-center">
        <li>
          <%= link_to "Incomplete",
            todos_path(status: "incomplete"),
            class: "inline-block py-4 px-4 text-sm font-medium text-center text-gray-500 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300" 
          %>
        <li>
          <%= link_to "Complete",
            todos_path(status: "complete"),
            class: "inline-block py-4 px-4 text-sm font-medium text-center text-gray-500 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300"
          %>
        </li>
    </ul>
    </div>
    <% unless params[:status] == "complete" %>
      <div class="py-2 px-4">
        <%= render "form", todo: Todo.new %>
      </div>
    <% end %>
    <ul id="todos">
      <%= render @todos %>
    </ul>
  <% end %>
</div>

The index view now wraps the todo content in a todos-container <turbo-frame>. As with the edit links for each individual todo, this Turbo Frame will scope navigation within the frame, allowing the list of Todos to be updated without changing the content on the rest of the page.

Inside of the new todos-container frame, we added links to view incomplete and complete todos. Logic to hide the new todo form when viewing complete todos was also added, since newly created todos are always incomplete.

Because the links to view incomplete and complete todos are within the todos-container Turbo Frame, each time those links are clicked, Turbo will replace the content of the todos-container with updated content from the server.

Conveniently, we do not need to change anything about the index action to render Turbo Frame content. Even though the entire page re-renders when the index action is called, Turbo will extract the todos-container frame from the response and discard the rest. If that small bit of inefficiency bothers you, it is possible to be more efficient.

With this change in place, we have a slight problem with the status toggle behavior. Right now, when the user marks a todo as complete, the todo is updated but it stays on the list. Instead, when a todo’s status is updated, we would like to remove it from the list.

Implementing this functionality will require adding a new, non-RESTful action to the TodosController. We will call this new action change_status. Start in the TodosController:

before_action :set_todo, only: %i[ show edit update destroy change_status ]

def change_status
  @todo.update(status: todo_params[:status])
  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.remove("#{helpers.dom_id(@todo)}_container") }
    format.html { redirect_to todos_path, notice: "Updated todo status." }
  end
end

Here, we updated the set_todo before_action to set the @todo instance variable when change_status is called and we defined change_status.

change_status updates the status of the given todo and then removes that that todo from the DOM. This will work for marking todos as complete and for marking them as incomplete — either way, we just target the id of the <li> and use a Turbo Stream remove action.

We added this new action because the update action we used in the first implementation of this feature use a replace Turbo Stream action, instead of removing the todo from the DOM. We could have hacked the update action to handle status changes differently or created a whole new TodoStatusChangesController for this, but there’s no reason to do that in our learning application.

Update config/routes.rb to add the new route to the application:

resources :todos do
  patch :change_status, on: :member
end

And finally, update the todo partial one last time to use the change_status_todo_path on the status toggle buttons:

<% if todo.complete? %>
  <%= button_to change_status_todo_path(todo, todo: { status: 'incomplete' }), class: "bg-green-600 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
    <span class="sr-only">Mark as incomplete</span>
    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
    </svg>
  <% end %>
<% else %>
  <%= button_to change_status_todo_path(todo, todo: { status: 'complete' }), class: "bg-gray-400 px-4 py-2 rounded hover:bg-green-700", method: :patch do %>
    <span class="sr-only">Mark as complete</span>
    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
    </svg>
  <% end %>
<% end %>

With that last change in place, you can refresh the page and see that todos are now grouped into tabs. Toggle the status on a few todos and see that they are removed from the list of todos. Change tabs and see that the todo list updates:

A screen recording of a user of a web application adding and removing todo items from a list.

Our application is so small that there’s no real way to tell that changing tabs is only updating the content of the todos-container Turbo Frame, right? It could just be updating the whole page and we would never be able to tell. A quick way to test the Turbo Frame out (and to see why partial page updates can be so useful) is to add a dummy input to the page, outside of the todos-container frame:

A screen recording of a user of a web application entering text into an input and then navigating a list of todos while the input text stays in place.

Neat!

Degrading gracefully

Throughout this application, we use respond_to blocks to render responses to Turbo Stream requests. The nice thing about this approach is that we always have a format.html back up ready to go if a user does not have JavaScript enabled in their browser.

Because we constructed our application in this way, the application remains fully functional even when JavaScript is disabled. The partial page updates powered by Turbo give way to regular full page turns. Turbo applications, constructed thoughtfully, tend to rely less on JavaScript to function, making it easier to build applications that can gracefully fall back to normal, server rendered HTML and full page turns.

A screen recording of a user of a web application disabling JavaScript in their browser and then using the todo application without any issues

Depending on your application’s audience, this may not be a top priority, but Turbo-powered applications give you a solid base to build from if your application needs to serve JavaScript-free users.

Wrapping up

Today we built a Turbo-powered todo application, using Turbo Streams and Turbo Frames to make fast, efficient page updates in response to user actions.

This simple application served as a base to explore the basics of Turbo Streams and Turbo Frames and gave us a chance to debunk a few common misconceptions about Turbo Streams in the process.

As you move forward in your Turbo journey, remember that Turbo Streams are for responding to form submissions. Streams give you the tools to update one or many elements after a form submission. You can render Turbo Streams inline in a controller, or from views.

Turbo Frames are for scoping GET requests to a single piece of the page. Use Turbo Frames to add tabbed content to a page, to power search and filter interfaces, or for inline editing like we saw today. Turbo Frames always replace the entire content of the target frame, and only one frame can be updated per GET request.

If you need more sophisticated update behavior (like appending items) or you need to update multiple elements at once, you cannot (easily) use a Turbo Frame.

In this tutorial, we looked at basic use cases for Streams and Frames; however, we only just scratched the surface of what you can do with Turbo.

To continue learning, a thorough review of the Turbo reference documentation is a good starting point. In particular, familiarizing yourself with the events Turbo emits is important for more advanced use cases. Understanding Turbo Frame options is also important — functionality like eager and lazy loaded frames, breaking out of frames, and targeting frames from the outside all help unlock powerful Turbo Frame-powered experiences.

In addition to the Turbo documentation, you might find my more in-depth explorations of using Turbo Streams and Turbo Frames in Rails useful.

For a much deeper dive into building a real application with Turbo, Alexandre Ruban’s (in-progress) Hotrails course is a great resource.

That’s all for today. As always, thanks for reading!