FV's blog

Hotwire, A basic to-do list

Hotwire and View Components: A basic “to-do list”

Published on: 2024-08-02

Building a To-Do List Application

Let’s build a simple to-do list application without using a single line of javascript. We’ll utilize Turbo for interactive list updates and View Components to encapsulate the task creation and display elements.

For this application, we’ll use the following stack:

  • Ruby on Rails 7.x
  • Hotwire (Turbo)
  • Sqlite
  • Tailwind CSS
  1. Setting up the Rails Project. Begin by creating a new Rails project:
rails new todolist -T -M
  1. Install Tailwind CSS

    It would be very beneficial to consider Tailwind CSS over plain CSS or SASS for its dramatic impact on CSS maintainability and UI design productivity.

    First, install the tailwindcss-rails gem

bundle add tailwindcss-rails

run the Tailwind installer, which will set up Tailwind:

rails tailwindcss:install

Next, configure the template paths in the config/tailwind.config.js file. Using these files, Tailwind generates the final CSS.

const defaultTheme = 
      require('tailwindcss/defaultTheme')

module.exports = {
  content: [
    './public/*.html',
    './app/helpers/**/*.rb',
    './app/javascript/**/*.js',
    './app/views/**/*.{erb,haml,html,slim}',
    './app/components/**/*.{erb,haml,html,slim}'
  ],
  theme: {
    extend: {
      fontFamily: {
        sans: ['Inter var', 
                ...defaultTheme.fontFamily.sans],
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
    require('@tailwindcss/container-queries'),
  ]
}
  1. Creating the Todo Model
rails g model Todo task:string completed:boolean

run migrations:

rails db:migrate
  1. Designing the View Component

Add view component support:

bundle add view_component

Create a View Component:

rails generate component Todolist::Item

After running the command, you might end up with:

app/
└── components/
    └── todolist/
        ├── item_component.html.erb
        └── item_component.rb
  1. Implementing the View Component

Define the necessary logic in item_component.rb:

module Todolist
  class ItemComponent < ViewComponent::Base
    def initialize(todo:)
      super
      @todo = todo
    end
  end
end

Make the following changes to item_component.html.erb:

<div id="todo_<%= @todo.id %>">
  <%= form_with model: @todo, remote: true, 
      html: { id: dom_id(@todo), 
              class: "flex gap-4 pt-6 
                      items-center" } 
      do |form| %>

    <%= form.label :completed, "Completed", 
        class: "sr-only", 
        for: "completed_#{dom_id(@todo)}" %>
    <%= form.check_box :completed, 
        id: "completed_#{dom_id(@todo)}", 
        class: 'size-6' %>
    
    <%= form.label :task, "Task", 
        class: "sr-only", 
        for: "task_#{dom_id(@todo)}" %>
    <%= form.text_field :task, 
        id: "task_#{dom_id(@todo)}", 
        class: 'rounded-xl w-full md:w-auto' %>

    <%= form.submit 'Update', 
        class: 'bg-blue-300 p-2 rounded-xl 
                cursor-pointer hover:bg-gray-400
                w-fit text-center w-auto' %>

    <%= link_to 'Delete', todo_path(@todo), 
        method: :delete, title: 'Delete', 
        data: { turbo_confirm: 'Are you sure?', 
                turbo_method: :delete }, 
        class: 'bg-red-400 p-2 rounded-xl 
                cursor-pointer hover:bg-gray-400 
                w-fit text-center w-auto' %>
  <% end %>
</div>

This code generates a form for each to-do item on your list. It allows you to edit the task, mark it as complete or delete it, all while providing a smooth user experience with AJAX updates.

alt text

In essence:

  • You’ve enabled AJAX with remote: true.
  • You’ve provided instructions for Turbo on the client-side.
  • Your server-side code now needs to handle the AJAX request and send back the right content to enable the seamless update.
  1. Creating the todos view

First, update the config/routes.rb to:

Rails.application.routes.draw do
  resources :todos, except: %i[edit show new]
  root 'todos#index'
end

add the file app/views/todos/index.html.erb:

<div class="flex flex-col gap-4">
  <h1 class="font-bold">Todo List</h1>

  <%= render 'form' %>
  
  <div id="messages" class="text-red-500"></div>

  <div id="todos" class="pr-8 flex flex-col">
    <% @todos.all.each do |todo|  %>
      <%= render 
          Todolist::ItemComponent.new(todo: todo) 
      %>
    <% end %>
  </div>
</div>

the _form.html.erb partial is defined as follows:

<%= form_with 
    model: Todo.new, 
    url: todos_path, 
    local: true, 
    id: 'form', 
    class: 'flex gap-4', 
    autocomplete: false, 
    html: {  autocomplete: "off" } do |f| %>
  <%= f.label :task, 'Task', class: 'sr-only' %>
  <%= f.text_field :task, 
      placeholder: 'New task', 
      class: 'rounded-xl' %>
  <%= f.submit 'Add', 
      class: 'bg-gray-300 p-2 rounded-xl 
              cursor-pointer hover:bg-gray-400' 
      %>
<% end %>
  1. Adding the Controller

Create a TodosController:

rails generate controller TodosController

this generates todos_controller.rb:

class TodosController < ApplicationController
  before_action :set_todo, only: %i[update destroy]

  def index
    @todos = Todo.all
  end

  def create
    @todo = Todo.new(todo_params)
    if @todo.save
      render turbo_stream: [
        turbo_stream.append('todos', 
          Todolist::ItemComponent.new(todo: 
          @todo)),
        turbo_stream.replace('form', 
          partial: 'todos/form'),
        turbo_stream.update('messages', '')]
    else
      render_error_response
    end
  end

  def update
    if @todo.update(todo_params)
      render turbo_stream: [
        turbo_stream.replace("todo_#{@todo.id}", 
          Todolist::ItemComponent.new(todo: 
          @todo)),
        turbo_stream.update('messages', 
          'Task was successfully updated.')]
    else
      render_error_response
    end
  end

  def destroy
    @todo.destroy
    render turbo_stream: 
      turbo_stream.remove("todo_#{@todo.id}")
  end

  private

  def render_error_response
    @errors = @todo.errors.full_messages.join(', ')
    render turbo_stream: 
      turbo_stream.update('messages', @errors)
  end

  def set_todo
    @todo = Todo.find(params[:id])
  end

  def todo_params
    params.require(:todo).permit(:task, :completed)
  end
end

Interaction Flow:

  1. Initial Page Load: The index action renders the page with the list of tasks.

  2. Creating a new task:

  • User fills out the form and submits it.
  • The create action handles the request, saves the task, and sends back Turbo Stream instructions to:
    • Render only the new task (view component) to the list.
    • Reset the form.
    • Clear any messages.

alt text

alt text

  1. Updating a task:
  • User interacts with the form within an item (e.g., checks the “Completed” checkbox or updates the task text).
  • The form submits the changes using AJAX (due to remote: true).
  • The update action processes the update and responds with Turbo Stream instructions to:
    • Replace the existing item only with the updated version.
    • Display a success message.

alt text

  1. Deleting a task:
  • The user clicks “Delete”.
  • The link’s data attributes trigger a confirmation dialog (using turbo_confirm) and specify the delete method.
  • The destroy action handles the deletion and sends back a Turbo Stream instruction to remove the item from the DOM.

Benefits of this Approach:

  • Improved User Experience: Turbo Streams provide a seamless and responsive experience for users by updating specific parts of the page without full-page reloads.

  • Code Organization: ViewComponents encapsulate the presentation logic for individual todo items, making the code more modular and maintainable.

  • Reduced Data Transfer: Turbo Streams optimize performance by transmitting only the necessary HTML fragments, reducing bandwidth consumption and improving page load times. They also offer several actions for manipulating content on a web page, allowing for efficient updates without full page reloads. These actions include:

    • Append: Adds new content to an existing element.
    • Replace: Replaces the content of a target element with new content.
    • Update: Modifies the attributes or properties of an existing element.
    • Remove: Deletes a specific target element from the page.
    • Prepend: Inserts new content before the target element.
    • After: Inserts new content after the target element.

Conclusion

Hotwire and View Components in Rails 7 create a powerful combination for building modern web applications. This duo leverages Hotwire’s server-driven approach and View Components’ modularity to deliver dynamic and engaging user experiences, while maintaining a clean and efficient codebase. This approach not only improves the user experience but also streamlines development by:

  • Enhancing Code Maintainability: View Components create modular and reusable building blocks, making code more organized and easier to understand.

  • Boosting Development Speed: Hotwire minimizes the need for complex JavaScript, allowing for faster development cycles.

  • Optimizing Performance: Turbo Streams ensure efficient data transfer, leading to improved performance and a smoother user experience.

Profile
This work by Frank Vielma is licensed under a Creative Commons Attribution-NonCommercial 4.0 License.
© 2024 Frank Vielma