Building a Live Updating List of Items in Rails 7 with Hotwire

Hotwire is the new default front-end framework for Rails 7 applications. Using a combination of Stimulus and Turbo, it allows for significant speed increases due to the use of Ajax requests instead of page reloads, and provides the ability to easily build dynamic applications by slicing up pages and simply adding a few lines of code.

To gain an understanding of how to set something like this up, let's build an application that stores Items in a Backpack. We'll create a new Rails 7 project and set up our models and associations. Then we will build out an interface with two lists of Items, one set that is inside the Backpack and another that is not. When Items are added or removed, the interface will update automatically using Hotwire.

Let's get started. Feel free to checkout the full respository on GitHub if you want to follow along without typing everything.

Setting up the project

First let's create a new Rails 7 project. Make sure you are using an appropriate version of Ruby and have Rails 7 installed by using ruby -v and rails -v.

$ rails new hotwire-backpack && cd hotwire-backpack
      create
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes
      create  Gemfile
         run  git init from "."
[...]

Building models and associations

Next we'll generate models for Backpack and Item. We'll give them both name columns but nothing else.

$ rails g model Backpack name:text
rails g model Backpack name:text
      invoke  active_record
      create    db/migrate/20230308235818_create_backpacks.rb
      create    app/models/backpack.rb
      invoke    test_unit
      create      test/models/backpack_test.rb
      create      test/fixtures/backpacks.yml
$ rails g model Item name:text
      invoke  active_record
      create    db/migrate/20230308235921_create_items.rb
      create    app/models/item.rb
      invoke    test_unit
      create      test/models/item_test.rb
      create      test/fixtures/items.yml

Now we can create our join table and model that will allow us to store which Items are in each Backpack.

$ rails g model BackpackItem backpack:references item:references
      invoke  active_record
      create    db/migrate/20230309000222_create_backpack_items.rb
      create    app/models/backpack_item.rb
      invoke    test_unit
      create      test/models/backpack_item_test.rb
      create      test/fixtures/backpack_items.yml

Now we need to finalize our associations between our join model and our main models.

class Backpack < ApplicationRecord
  has_many :backpack_items
  has_many :items, through: :backpack_items
end
/app/models/backpack.rb
class Item < ApplicationRecord
  has_many :backpack_items
  has_many :backpacks, through: :backpack_items
end
/app/models/item.rb

Now we can run our migrations and we've got our basic models completed.

$ rails db:migrate
== 20230308235818 CreateBackpacks: migrating ==================================
-- create_table(:backpacks)
   -> 0.0009s
== 20230308235818 CreateBackpacks: migrated (0.0010s) =========================

== 20230308235921 CreateItems: migrating ======================================
-- create_table(:items)
   -> 0.0009s
== 20230308235921 CreateItems: migrated (0.0009s) =============================

== 20230309000222 CreateBackpackItems: migrating ==============================
-- create_table(:backpack_items)
   -> 0.0014s
== 20230309000222 CreateBackpackItems: migrated (0.0015s) =====================

Controller, view, and routes

We'll need a BackpacksController with index and show actions, along with their associated views.

rails g controller backpacks index show
      create  app/controllers/backpacks_controller.rb
       route  get 'backpacks/index'
              get 'backpacks/show'
      invoke  erb
      create    app/views/backpacks
      create    app/views/backpacks/index.html.erb
      create    app/views/backpacks/show.html.erb
      invoke  test_unit
      create    test/controllers/backpacks_controller_test.rb
      invoke  helper
      create    app/helpers/backpacks_helper.rb
      invoke    test_unit

Now let's edit our new BackpacksController to give it the initial data it needs to populate the views. We'll include all the Items in the show action because that's where our main interface will be.

class BackpacksController < ApplicationController
  def index
    @backpacks = Backpack.all
  end

  def show
    @backpack = Backpack.find(params[:id])
    @items = Item.all
  end
end
/app/controllers/backpacks_controller.rb

In the index.html.erb view let's create a basic list of all the Backpacks so that we can easily access our show view for each one.

<h1>Backpacks</h1>

<ul>
  <% @backpacks.each do |backpack| -%>
  <li><%= link_to backpack.name, backpack %></li>
  <% end -%>
</ul>
/app/views/backpacks/index.html.erb

We'll set our root route to point to this view since we don't have anything else in the application. We can also clean up the other routes and put them under a resources declaration.

Rails.application.routes.draw do
  root "backpacks#index"

  resources :backpacks, only: [:index, :show]
end
/config/routes.rb

Finally, let's create a Backpack and some Items so we have a little data to work with. You can do this in the console if you want but I'll put them in the seeds so that we can recreate them and store them in version control.

BackpackItem.destroy_all
Backpack.destroy_all
Item.destroy_all

Backpack.create!(
  name: "Danny's Backpack",
)

%w[Hammer Notebook Phone Hat Alligator].each do |item|
  Item.create!(
    name: item,
  )
end
/db/seeds.rb

Run your seeds and then the server to make sure everything is working as expected. At http://localhost:3000 you should see your one Backpack with a link to view it show view.

$ rails db:seed
$ rails s

Hotwire

Finally we get to the fun part: setting up our last remaining view to use Hotwire for moving Items in and our of our Backpack.

First thing we're going to do is add a couple new routes for adding and removing Items. We'll use these new routes to help generate links in our interface for each Item.

Rails.application.routes.draw do
  root "backpacks#index"

  resources :backpacks, only: [:index, :show]

  post '/backpacks/:id/add/:item_id', to: 'backpacks#add_item', as: :add_item_backpack
  delete '/backpackss/:id/remove/:item_id', to: 'backpacks#remove_item', as: :remove_item_backpack
end
/config/routes.rb

Next we're going to create our interface, containing a list of Items currently in our Backpack along with another list of Items that are not. Both lists will have links next to them to allow us to add or remove them respectively. We will use the routes we just created to create the links, passing the id of the Backpack as well as the item_id of the Item being added or removed.

<h1><%= @backpack.name %></h1>

<%= turbo_frame_tag "backpack-items" do %>
  <h2>Current Items</h2>
  <div class="current-items">
    <ul>
      <% @backpack.items.each do |item| %>
      <li><%= item.name %> <%= link_to "Remove", remove_item_backpack_path(id: @backpack.id, item_id: item.id), data: { "turbo-method": :delete } %></li>
      <% end %>
    </ul>
  </div>

  <h2>Remaining Items</h2>
  <div class="remaining-items">
    <ul>
      <% (@items - @backpack.items).each do |item| %>
      <li><%= item.name %> <%= link_to "Add", add_item_backpack_path(id: @backpack.id, item_id: item.id), data: { "turbo-method": :post } %></li>
      <% end %>
    </ul>
  </div>
<% end %>
/app/views/backpacks/show.html.erb

The most important thing to notice is the turbo_frame_tag which wraps both lists and defines this as a frame which can be updated by Hotwire without a page reload. It will simply be replaced by server-rendered HTML after the Ajax request is complete.

Also note the turbo-method sections of the link_to helpers which defines the type of HTTP request that Turbo will be using rather than the standard GET of a normal link. These match the routes we created as well.

Finally let's update our BackpacksController to include two new actions which we setup routes for earlier, add_item and remove_item.

class BackpacksController < ApplicationController
  before_action :set_backpack, only: [:show, :add_item, :remove_item]
  before_action :set_item, only: [:add_item, :remove_item]

  def index
    @backpacks = Backpack.all
  end

  def show
    @items = Item.all
  end

  def add_item
    @backpack.items << @item

    respond_to do |format|
      format.html { redirect_to backpack_path }
    end
  end

  def remove_item
    @backpack.items.delete(@item)

    respond_to do |format|
      format.html { redirect_to backpack_path }
    end
  end

  private

  def set_backpack
    @backpack = Backpack.find(params[:id])
  end

  def set_item
    @item = Item.find(params[:item_id])
  end
end
/app/controllers/backpacks_controller.rb

Take a look at the two before_action filters near the top and the private methods near the bottom that they reference. These simply set an instance variable for the Backpack and Item in a reusable way so that we don't have to repeat code across multiple actions.

We've also added two new actions, add_item and remove_item, which correspond to the routes and link_to helpers we've created. After making the change to the Backpack by appending << or calling delete, we use redirect_to to go back to the same page we were already on.

However because we are using Hotwire, the redirect will just return the necessary HTML through the Ajax response and replace the Turbo Frame we wrapped our Item lists in. Without adding any explicit JavaScript at all.

Run rails s once again and give it a try. When you click the links the Items should be added and removed from the Backpack list nearly instantaneously without a page reload. All you need to add is some actual CSS to make this into a full-fledged dynamic web application.

Pretty neat.

Conclusion

Hotwire is a very nice addition to the default Rails framework that allows dynamic applications to be created without much effort at all. I personally was never a huge fan of Turbolinks which started shipping by default in Rails 4, but Hotwire is much more robust and really makes a noticeable difference when developing fast and intuitive user interfaces.

Don't forget to checkout the repository on GitHub and feel free to contact me if you have any questions.