Turbo Rails 101: Building a todo app with Turbo11 Feb 2022
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:
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!
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:
And then scaffold up a
Todo resource. From your terminal again:
Update migration to set default value for status:
Finally, create and migrate the database:
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.
Start by replacing the content of the index view,
app/views/todos/index.html.erb, with the following:
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.
app/views/todos/_todo.html.erb to render each todo properly inside of the
form partial we render in the index view needs a few adjustments too. In
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
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
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:
And then fill that new file in:
Our first look at a Turbo Stream! In
create.turbo_stream.erb we render two
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.
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:
Turbo sets this header automatically on all
DELETE form submissions, with no intervention required from the developer.
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
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, 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 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
<li> elements in
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.
turbo-rails, developers can easily
broadcast updates from models and background jobs to send
<turbo-stream> snippets over WebSockets with
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:
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:
Here, we added a unique id to each
<li>. Nested within the
<li>, we added a
<turbo-frame> using the
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
turbo_frame_tag has an id that matches the
turbo_frame_tag in the
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
From your terminal:
Update the new
update.turbo_stream.erb view like this:
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
TodosController to respond to Turbo Stream requests:
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
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
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
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.
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:
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.
TodosController, update the
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
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:
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.
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
The index view now wraps the todo content in a
<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
Here, we updated the
before_action to set the
@todo instance variable when
change_status is called and we defined
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
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.
config/routes.rb to add the new route to the application:
And finally, update the todo partial one last time to use the
change_status_todo_path on the status toggle buttons:
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:
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
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
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.
That’s all for today. As always, thanks for reading!