Turbo Rails 101: Building a todo app with Turbo
11 Feb 2022When 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!
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:
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.
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:
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>
:
The form
partial we render in the index view needs a few adjustments too. In app/views/todos/_form.html.erb
:
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:
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:
And then fill that new file in:
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.
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 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
:
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 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:
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-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
:
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:
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 create.turbo_stream.erb
.
Update the 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 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
:
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:
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:
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:
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:
Back to app/views/todos/_todo.html.erb
:
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 TodosController
:
Then update app/views/todos/index.html.erb
:
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
:
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:
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 todos-container
frame:
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.
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!