Mateus Nava

Mateus Nava

November 25, 2023

To-do list using only turbo-stream (Rails Hotwire)

By israel palacio (
By israel palacio (

There are many examples of to-do lists on the Internet, but this one is special because my idea is to use only the turbo-stream to update the list!

Should I use turbo-stream to create a to-do list? Probably not, you can get almost the same result with just one turbo-frame (maybe just turbo-drive), which means less complexity. But I think this is a good example to understand the power of turbo-streams.

The application

TO-DO List


Let's start creating the new project:
rails new todo
cd todo
rails g resource task description:string completed:boolean
rails db:migrate



# config/routes.rb

resources :tasks
root "tasks#index"


# app/controllers/tasks_controller.rb

class TasksController < ApplicationController
  before_action :set_task, only: [:update, :destroy]

  def index
    @tasks = Task.all

  def create
    @task = Task.create(task_params)

  def update

  def destroy


  def set_task
    @task = Task.find(params[:id])

  def task_params
    params.require(:task).permit(:description, :completed)

CSS File

I'm not a designer, which means that this is an engineering design, i.e. a bad one 🙃. 
body {
  font-family: 'Roboto', sans-serif;
  background-color: #FFFBF5;

hr {
  margin-bottom: 40px;

.tasks {
  width: 50rem;
  margin: 0 auto;

.tasks__item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #ccc;

 .tasks__item:hover {
  background-color: #F7EFE5;

.tasks__item__completed {
  text-decoration: line-through;

.tasks__item__button {
  padding: 10px;
  background-color: #0766AD;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;

.tasks__item__delete {
  display: inline;

.tasks__item__delete button {
  font-size: 0.8rem;
  text-decoration: none;
  color: #DED0B6;
  margin-left: 10px;
  background: none;
  border: none;
  cursor: pointer;

.tasks__item input[type="text"] {
  padding: 7px 5px;
  border: 1px solid #DED0B6;
  border-radius: 3px;
  width: 100%;
  color: #BBAB8C;
  margin-right: 20px;

.tasks__item input[type="text"]:focus {
  outline: none;
  border: 1px solid #`BBAB8C;

Index HTML

This view is to render the tasks list:
# app/views/tasks/index.html.erb

<h1>🚧 TODO List</h1>
<hr />

<ul id="tasks-list" class="tasks">
  <%= render partial: "task", collection: @tasks %>

<ul class="tasks" id="new-task">
  <%= render partial: "form", locals: { task: } %>

Task partial

This partial is for rendering a task:
# app/views/tasks/_task.html.erb
  id="<%= dom_id(task) %>"
  class="<%= class_names("tasks__item", "tasks__item__completed": task.completed) %>"
  <span class="tasks__item__description">
    <%= task.description %>
    <%= button_to "Delete",
      method: :delete,
      form: {
        class: "tasks__item__delete", data: { turbo_confirm: "Are you sure?" }
      } %>

  <% if task.completed %>
    <%= button_to "Uncheck",
      class: "tasks__item__button",
      method: :patch,
      params: { task: { completed: false } }
  <% else %>
    <%= button_to "Check",
      class: "tasks__item__button",
      method: :patch,
      params: { task: { completed: true } }
  <% end %>

Some interesting methods:
- class_names
- dom_id

Form partial

This partial is for rendering the form:
# app/views/tasks/_form.html.erb

<%= form_with(model: task, class: "tasks__item") do |form| %>
  <%= form.text_field :description, required: true, placeholder: "Enter description", autofocus: true %>
  <%= form.submit "Create", class: "tasks__item__button" %>
<% end %>

Create turbo-stream

This is a turbo-stream action. After creation, we would like to add a new item to the list and update the form to keep the input blank:
# app/views/tasks/create.turbo_stream.erb

<%= turbo_stream.append "tasks-list",
  partial: 'tasks/task',
  locals: { task: @task } %>

<%= turbo_stream.update "new-task",
  partial: 'tasks/form',
  locals: { task: } %>

Destroy turbo-stream

This is another turbo-stream action. After deletion, we would like to remove the element with the task ID:
# app/views/tasks/destroy.turbo_stream.erb

<%= turbo_stream.remove dom_id(@task) %>

Update turbo-stream

After updating, we would like to update the task ID element:
# app/views/tasks/update.turbo_stream.erb

<%= turbo_stream.replace dom_id(@task),  partial: 'tasks/task',  locals: { task: @task } %>


No need to write JavaScript 😊.