To Do List Turbo

To Do List Turbo is a task list application that allows you to create, edit, and remove tasks. It’s also possible to mark tasks as complete or incomplete.

In this article, we will learn how to build this application with Hotwire Turbo.

Introduction

In this article, we will develop together a to-do list application using the Hotwire Turbo framework. This application is quite simple but is sufficient to demonstrate the power of Hotwire Turbo.

In a nutshell, Hotwire Turbo is a framework that allows parts of a page to be updated on request, without the need to reload the entire page. It’s an alternative to using REST APIs and JavaScript and is a powerful ally for high performance. Read more about Hotwire Turbo.

Creating the Project

In your working environment, create a new project using the rails new command with the --css=tailwind option. This will create a Rails project with the Tailwind CSS framework installed.

1
rails new to-do-list-turbo --css=tailwind

Next, navigate to the folder of the created project.

1
cd to-do-list-turbo

Creating the Model

To create the model, we will use the Rails scaffold generator.

The model will have only two fields called description, which will be of type string, and complete, which will be of type boolean.

1
rails g scaffold Task description:string complete:boolean --skip-controller new show --no-jbuilder

Please note that we’ve added some options to the rails g scaffold command.

The --skip-controller new show option will skip the creation of the new and show actions in the controller.

The --no-jbuilder option will skip the creation of .json.jbuilder files, which are used to render data in JSON format.

Both of these features won’t be used in our application.

Therefore, let’s remove the views that won’t be used and were created by the scaffold. To do this, delete the new.html.erb and show.html.erb files located in app/views/tasks/.

Next, execute the database migrations with the following command.

1
rails db:migrate

Configuring Routes

Open the config/routes.rb file and set the application’s root to tasks#index.

Once again, since we won’t be using the new and show actions in our tasks_controller.rb, let’s remove these routes from resources, as shown in the code below.

1
2
3
4
Rails.application.routes.draw do
  root to: "tasks#index"
  resources :tasks, except: %i[ show new]
end

Configuring Tailwind

To ensure that Tailwind works correctly, let’s start the server using the command:

1
./bin/dev

This will compile the Tailwind configuration files and style our application properly.

Once this is done, you can access the application’s homepage at http://localhost:3000 and check if the page is styled correctly.

Adapting the Project for Turbo

So far, we have our routes configured, the model created, and the homepage styled in the default Rails way.

Turbo Frames are custom elements with their own set of HTML attributes and JavaScript properties.

Turbo Streams is a response format that allows you to update parts of an HTML page without discarding the rest of the page.

Now, let’s begin adapting our application to use Hotwire Turbo features by modifying the code and adding Turbo Frames and Turbo Streams where necessary.

Modifying the Homepage

Our application will have the new task creation form displayed on the homepage. This way, we can create new tasks without the need to be redirected to another page.

In index.html.erb, let’s remove the link to the new task creation page and replace it with the form for creating new tasks.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div class="w-full">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center">
    <h1 class="font-bold text-4xl">Tasks</h1>
  </div>

  <%= render "form", task: @task %>

  <div id="tasks" class="min-w-full">
    <%= render @tasks %>
  </div>
</div>

Please note that we are rendering the form by passing an @task object that hasn’t been defined yet.

To fix this, in our tasks_controller.rb, let’s define the @task object in the index method.

1
2
3
4
def index
  @tasks = Task.all
  @task = Task.new
end

With this done, the application’s homepage should look like this:

Homepage

At this point, if you try to create a new task, you will notice that nothing different happens.

This is because we haven’t configured the form to send data via Turbo, and the view to receive data via Turbo.

Configuring the Form

In the _form.html.erb file, let’s add the data-turbo-stream attribute to the form so that it gets submitted via Turbo.

To do this, simply add the attribute data: { turbo_stream: true } to the form.

1
<%= form_with(model: task, class: "contents", data: { turbo_stream: true }) do |form| %>

For this to work as expected, we need our controller to handle and respond to the request properly.

So, in our tasks_controller.rb, let’s adapt the create action as shown in the following code:

1
2
3
4
5
6
7
8
9
10
11
12
# POST /tasks or /tasks.json
def create
  @task = Task.new(task_params)

  respond_to do |format|
    format.turbo_stream do 
      if @task.save
        render turbo_stream: turbo_stream.prepend("tasks", partial: "tasks/task", locals: { task: @task })
      end
    end
  end
end

With this, after the record is successfully saved, the Turbo Stream will add the new record to the task list without the need to reload the page.

Important: This only works because in our index.html.erb, there is an element with id=tasks where the Turbo Stream will add the new record.

Before we continue, it’s important to address a few situations:

  • When trying to edit a record, our application still redirects the user to the edit page.
  • If the record isn’t saved successfully, nothing happens.
  • No notifications are displayed to the user after successfully creating a new record.

We will address these issues shortly.

Adding Notifications

As we are working with Turbo resources, we will use the turbo_frame_tag component to display success and error notifications.

To do this, create the _flash.html.erb file in views/layouts and add the following code:

1
2
3
4
5
6
7
8
9
10
11
12
<div class=" font-medium rounded-lg inline-block">
  <% case type %>
    <% when 'notice' %>
      <div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 " role="alert">
        <span class="font-medium">Notice:</span> <%= message %>
      </div>
    <% when 'alert' %>
      <div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 " role="alert">
        <span class="font-medium">Alert:</span> <%= message %>
      </div>
  <% end %>
</div>

Next, in views/layouts/application.html.erb, add the following code above the <%= yield %>

1
<%= turbo_frame_tag "flash", class:'absolute top-8' %>

Also, in the views section, in index.html.erb, you can remove the instructions:

1
2
3
<% if notice.present? %>
  <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>

Since this is no longer necessary, as the notifications will be displayed inside the flash element we created earlier.

Now, back to our tasks_controller.rb, let’s add the code to render flash messages in the create action:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# POST /tasks or /tasks.json
def create
  @task = Task.new(task_params)

  respond_to do |format|
    format.turbo_stream do 
      if @task.save
        render turbo_stream: [
          turbo_stream.update('flash', partial: "layouts/flash", locals: { type:'notice', message:"Task was successfully created."}),
          turbo_stream.prepend("tasks", partial: "tasks/task", locals: { task: @task })
        ]
      else
        render turbo_stream: turbo_stream.update('flash', partial: "layouts/flash", locals: { type:'alert', message:@task.errors.full_messages.join(', ')})
      end
    end
  end
end

Things might seem a bit confusing at this point, but let’s understand what’s happening.

  1. We created a partial called _flash.html.erb that will display success and error messages.
  2. We added a turbo_frame_tag with the id flash in views/layouts/application.html.erb to display the messages.
  3. In the controller, we send the update of the flash frame along with the update of the record so that messages and changes are displayed when requested.

Now, when creating a new task, the success message should be displayed as shown in the following image:

Success Message

To test if the error message is working, let’s add a presence validation to the description field in our task.rb model.

1
2
3
class Task < ApplicationRecord
  validates :description, presence: true
end

Now, when you click Save, the message should be displayed as shown in the following image:

image

Two of the previously reported issues have been resolved, but we still need to address the redirection problem when editing a record.

Editing a Record

To resolve the redirection problem when editing a record, we will use the turbo_frame_tag component to make each rendered task (@tasks) act as an individual component.

In the _task.html.erb file, let’s replace the line:

1
2
3
<div id="<%= dom_id task %>">
... 
</div>

with the following code:

1
2
3
<turbo-frame id="<%= dom_id task %>" >
...
</turbo-frame>

Remember, Turbo Frames are custom elements that behave somewhat like a component, allowing you to replace parts of a page without reloading the entire page.

With this change, you’ll notice that when you click on edit, the message Content missing is displayed.

Content missing message

This happens because the page being requested doesn’t have an element with the same ID as the element being replaced.

To resolve this, simply add the same element that’s being replaced on the requested page.

In views/tasks/edit.html.erb, add the following code:

1
2
3
<turbo-frame id="<%= dom_id @task %>" >
...
</turbo-frame>

If everything works as expected, the edit form should be displayed without the need to reload the page.

Edit Form

To keep things organized, we can simplify the views/tasks/edit.html.erb file to:

1
2
3
<turbo-frame id="<%= dom_id @task %>">
  <%= render "form", task: @task %>
</turbo-frame>

At this point, the edit form is still not working correctly.

This is happening because our controller is still trying to respond in a format other than turbo_stream.

So, let’s adjust the update action with the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# PATCH/PUT /tasks/1 or /tasks/1.json
def update
  respond_to do |format|
    if @task.update(task_params)
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.update(@task),
          turbo_stream.update('flash', partial: "layouts/flash", locals: { type:'notice', message:"Task ID:#{@task.id} was successfully updated."}),
        ]
      end
    else
      format.turbo_stream do
        render turbo_stream: turbo_stream.update('flash', partial: "layouts/flash", locals: { type:'alert', message:"Task ID: #{@task.id} - #{@task.errors.full_messages.join(', ')}"})
      end
    end
  end
end

Important: Notice that in the create action, we used the turbo_stream.prepend method, while for update, we used the turbo_stream.update method.

prepend adds content to the beginning of the element, while update replaces the content of the current element with the desired content.

To better understand the difference between the methods, refer to the documentation.

Turbo Stream Methods

Deleting a Record

By default, the button to delete a record is displayed in the show action, which we removed from our application.

So, let’s add it manually directly in _task.erb.html.

Replace the line:

1
<%= link_to "Show this task", task, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>

With the following code:

1
<%= button_to "Destroy this task", task_path(task), class:'rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium', method: :delete %>

When you click on delete, the Content missing message is displayed again.

This happens because the content of the element was removed, but the element itself still exists.

To resolve this, let’s change the destroy action in the tasks_controller to respond in Turbo Stream format.

1
2
3
4
5
6
7
8
9
10
11
12
13
# DELETE /tasks/1 or /tasks/1.json
def destroy
  @task.destroy

  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: [
        turbo_stream.remove(@task),
        turbo_stream.update('flash', partial: "layouts/flash", locals: { type:'notice', message:"Task ID:#{@task.id} was successfully deleted."}),
      ]
    end
  end
end

Note that we are now using the turbo_stream.remove method to remove the element from the page and once again updating the flash element to display the message.

Turbo Stream Remove

Testing Turbo Features

Before we style our application, it’s interesting to observe the requests and responses directly from the browser.

Open the browser console, then go to the Network tab and perform some actions in the application.

Network Tab

Notice that when you first access the page, a series of resources like fonts, scripts, and stylesheets are loaded. However, when interacting with the application, only one request is made to the server, and the content is updated without the need to completely reload the page.

With this, we can see that the application is much faster, and data consumption is much lower, resulting in a better user experience and server resource savings.

Now that our application is working, we can style it using the Tailwind CSS framework.

Styling the Application

Since the focus of this article doesn’t cover frontend concepts, you can customize the styling as you prefer or simply replace the code in the files with the following:

app/assets/stylesheets/application.tailwind.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@tailwind base;
@tailwind components;
@tailwind utilities;


@layer components {

  body {
    @apply bg-gray-700;
  }

  .container {
    @apply px-4 mx-auto mt-4 md:mt-28 shadow-lg;
  }

  .inner-container {
    @apply p-4 relative space-y-4 bg-white rounded-lg;
  }

  .contents {
    @apply w-full space-y-2 md:space-y-0 items-start justify-between gap-2;
  }

  .task {
    @apply w-full space-y-2 md:space-y-0 md:flex items-center;
  }

  .task-container{
    @apply flex md:grow items-center justify-between gap-2;
  }
  
  .task-icon {
    @apply rounded-lg py-2 px-2.5 bg-gray-100 inline-block font-medium;
  }

  .task-content {
    @apply grow flex items-center justify-between rounded-md border outline-none w-4/5 py-2.5 px-4 my-auto;
  }
  
  .task-badge {
    @apply rounded-md border outline-none py-0.5 px-2 my-auto  items-center bg-blue-100 border-blue-400 text-blue-700  text-xs sm:text-sm;
  }

  .task-buttons {
    @apply flex items-center justify-between gap-2;
  }

  .title {
    @apply font-bold text-2xl lg:text-4xl text-center py-4;
  }

  .form-input {
    @apply block rounded-md border border-gray-200 outline-none w-full;
  }

  .btn-primary {
    @apply  rounded-lg py-2.5 px-5 bg-blue-600 hover:bg-blue-700  text-white inline-block font-medium cursor-pointer;
  }
  .btn-primary-light {
    @apply  rounded-lg py-2.5 px-5 bg-gray-100 hover:bg-blue-200  text-blue-600 inline-block font-medium cursor-pointer;
  }
  .btn-danger-light {
    @apply  rounded-lg py-2.5 px-5 bg-gray-100 hover:bg-red-200  text-red-600 inline-block font-medium cursor-pointer;
  }
  .btn-secondary {
    @apply  rounded-lg py-2.5 px-5 bg-gray-100 hover:bg-gray-200   inline-block font-medium cursor-pointer;
  }
}

app/views/layouts/application.html.erb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
  <head>
    <title>ToDoListTurbo</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">
      <%= turbo_frame_tag "flash", class:'absolute top-8' %>
      <%= yield %>
    </main>
  </body>
</html>

app/views/tasks/_form.html.erb

1
2
3
4
5
6
7
8
9
10
11
<%= form_with(model: task, class: "contents sm:flex", data: { turbo_stream: true }) do |form| %>

  <div class="grow">
    <%= form.text_field :description, placeholder:'Insert your Task', class: "form-input" %>
  </div>

  <div class="flex gap-2">
    <%= link_to "Cancel", tasks_path, class:'btn-secondary w-full text-center' if action_name == 'edit'%>
    <%= form.submit class: "btn-primary w-full" %>
  </div>
<% end %>

app/views/tasks/_task.html.erb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<turbo-frame id="<%= dom_id task %>" class="task" >

  <div class="task-container">

    <% if task.complete %>
      <div class="task-icon bg-blue-100 border-blue-400 text-blue-700">
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-7 h-7">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      </div>
    <% else %>
      <div class="task-icon ">
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-7 h-7">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
        </svg>
      </div>
    <%end%>

    <div class="task-content">
      <p class="grow">
        <%= task.description %>
      </p>

      <% if task.complete %>
        <p class="task-badge hidden md:inline-flex">
          completed <%= time_ago_in_words(task.updated_at)%> ago
        </p>
      <% end %>
    </div>

  </div>

  <div class="task-buttons">
    <div class="flex gap-2">
      <% if task.complete %>
        <p class=" md:hidden task-badge">completed <%= time_ago_in_words(task.updated_at)%> ago</p>
      <%else%>
        <%= button_to task_path(task), class:'btn-danger-light md:ml-2', method: :delete do %>
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
            <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
          </svg>
        <%end%>

        <%= link_to edit_task_path(task), class: "btn-primary-light" do %>
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
            <path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
          </svg>
        <%end%>
      <% end %>
    </div>

    <%= button_to task_path(task), params: {task: {complete: !task.complete }}, class:"btn-primary-light", method: :patch do %>
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
        <path stroke-linecap="round" stroke-linejoin="round" d="<%= task.complete ? 'M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3' : 'M4.5 12.75l6 6 9-13.5' %>" />
      </svg>
    <%end%>
  </div>

</turbo-frame>

app/views/tasks/index.html.erb

1
2
3
4
5
6
7
8
9
10
<div class="inner-container">

  <h1 class="title">Tasks</h1>

  <%= render "form", task: @task %>

  <div id="tasks" class="grid gap-2">
    <%= render @tasks %>
  </div>
</div>

Completing Tasks

Notice that the checkbox for the complete attribute has been removed from the form. This is because we will now mark tasks as complete or incomplete directly in the task list, without the need to edit the record to change its status.

In summary, this behavior is implemented in _task.html.erb through the instructions:

1
2
3
4
5
<%= button_to task_path(task), params: {task: {complete: !task.complete }}, class:"btn-primary-light", method: :patch do %>
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
    <path stroke-linecap="round" stroke-linejoin="round" d="<%= task.complete ? 'M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3' : 'M4.5 12.75l6 6 9-13.5' %>" />
  </svg>
<%end%>

Basically, what this button does is send a PATCH request with the inverted complete parameter. In other words, if the task is complete, the complete parameter will be false, and vice versa.

Since our controller is already configured to interpret this request, the record will be updated automatically, and the complete attribute will be updated.

Final Result

Final Result


GitHub Repository

lucasgeron/to-do-list-turbo


Like this Project? Give your Feedback
Share Dynamic Hitcount Badge

Further Reading