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
class Item < ApplicationRecord
has_many :backpack_items
has_many :backpacks, through: :backpack_items
end
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
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>
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
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
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
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 %>
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
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.