Hotwire Handbook - Part 1
Recently I’ve been playing with Rails 7 and Hotwire.
This post is going to be published in stages and updated fairly regularly to complement the official Turbo Handbook and the other great examples out there. Many of which are linked in the “Sources” Section.
What can Hotwire do?
Hotwire allows for advanced interactivity, normally reserved for JavaScript SPAs, but with faster first-load times and a drastically simpler, more productive and happier developer experience.
Hotwire can do a lot, advanced interactivity and content updates without page updates. This handbook is going to touch the surface of its capabilities. We’re going to look at:
- Toggle buttons
- Content Updates
- Live Counters
- Pagination
- Global Counters
- Global Live Content Updates
- Inline Editing
- Tabbed Content
- Modals
What is Turbo
Turbo is part of Hotwire. Hotwire is HTML-over-the-wire, a modern approach to building web app which sends HTML instead of JSON to the browser. Why? Less JavaScript, smaller packet sizes, faster page load times and better developer experience (in my opinion). You can read more of the why on hotwired.dev
Turbo is the spiritual successor to Turbolinks. Hotwired.dev says:
The heart of Hotwire is Turbo. A set of complementary techniques for speeding up page changes and form submissions, dividing complex pages into components, and stream partial page updates over WebSocket. All without writing any JavaScript at all. And designed from the start to integrate perfectly with native hybrid applications for iOS and Android.
Prerequisites, Presumptions and how this handbook is laid out
This handbook presumes that you have a familiarity with Ruby on Rails, MVC web app frameworks and have installed turbo-rails following the steps here.
Each section of this handbook has a brief introduction, it will then give all the relevant code for context. I’ll then dig into the key bits of each code sample. The code can all be viewed with full context in the DailyBrew GitHub repo below.
Sources, References and Research
Official Docs: https://hotwired.dev/
Community: https://discuss.hotwired.dev/
David Colby: https://www.colby.so/
Sean P Doyle, Thoughtbot Hotwire Example: https://github.com/thoughtbot/hotwire-example-template
All the code examples here are part of the Daily Brew project and can be viewed in context here: https://github.com/phil-6/dailybrew
Toggle Buttons
(as Turbo Streams)
The user clicks a button, which updates something in our database, and we want this to be reflected on the front end. (Without using JavaScript)
This is used in a few places in Daily Brew. The subscription page and favourites toggles are the ones we’re going to look at in this handbook. The simpler example is the subscription toggle.
Core Concept: Controller responds to Turbo Stream request format, and re-renders the partial which contains the button. The button has an erb conditional based on the value which has been updated.
Code
Parent View
app/views/pages/subscription.html.erb
<%= render 'subscription_interest_toggle' %>
Button Partial
app/views/pages/_subscription_interest_toggle.html.erb
<div id="subscription_interest_toggle">
<% if current_user&.subscription_interest %>
<%= button_to update_subscription_interest_path,
method: :patch,
params: {user: { subscription_interest: false }},
class: "btn btn-complementary" do %>
I'm not interested anymore
<% end %>
<% else %>
<%= button_to update_subscription_interest_path,
method: :patch,
params: {user: { subscription_interest: true }},
class: "btn btn-complementary" do %>
I'm In!
<% end %>
<% end %>
</div>
Controller (excerpt)
app/controllers/registrations_controller.erb
# app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
def update_subscription_interest
current_user.update!(subscription_interest_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
'subscription_interest_toggle',
partial: 'pages/subscription_interest_toggle'
)
end
end
end
end
Key Bits
The key part here are the in the turbo stream response, which renders a turbo stream.
turbo_stream.replace(
'subscription_interest_toggle',
partial: 'pages/subscription_interest_toggle'
)
“replace” is the action. The
partial in the response will replace the existing element with the target DOM ID. 'subscription_interest_toggle'
is the HTML ID which the stream is targeting and the partial is what the target is going to be replaced with.
You could move the conditional to the controller, which would remove some logic from the view, however by doing it this way the response doesn’t have to worry which view to render, as it just reloads the button partial.
Content Updates
The way the favourites toggle works is very similar to the subscription toggle. The differences are that it's a create / destroy action instead of an update, and the turbo stream is extracted to and erb partial.
There are quite a few partials here. This structure is broken down more than it needs to be as we reuse the coffee partial in quite a few different places throughout the app. I’ll explain the key code snippets in more details below the larger examples which are included for additional context.
Code
Toggle Button Partial
app/views/favourites/_favourite_toggle.html.erb
<div id="favourite_toggle_<%= dom_id(coffee) %>">
<% if current_user&.favourites.find_by(coffee: coffee) %>
<%= button_to delete_favourite_path(coffee), method: :delete, class: 'link link-primary link-icon icon-line-1-4' do %>
<span class="tooltip-parent">
<i class="icon-basic-heart-1"></i>
<span class="tooltip-content right delay-slow">Remove from your favourites</span>
</span>
<% end %>
<% else %>
<%= button_to create_favourite_path(coffee), method: :post, class: 'link link-primary link-icon icon-line-1-4' do %>
<span class="tooltip-parent">
<i class="icon-basic-heart"></i>
<span class="tooltip-content right delay-slow">Add to your favourites</span>
</span>
<% end %>
<% end %>
</div>
Parent View
app/views/dashboard/index.html.erb
<section class="header">
<div class="row stats">
<span id="favourites_count"><%= current_user.favourites.count %></span> favourites
</div>
</section>
<section class="favourites">
<h2><%= link_to 'Your Favourites', favourites_path, class: 'link link-white link-title' %></h2>
<div id="dashboard_favourites" class="shelf row">
<% @favourites.each do |coffee| %>
<%= render 'favourites/favourite', coffee: coffee %>
<% end %>
</div>
</section>
Favourite Partial
app/views/favourites/_favourite.html.erb
<div id="dashboard_favourites_<%= dom_id(coffee) %>" class="favourite">
<%= render partial: 'coffees/coffee', locals: { coffee: coffee } %>
</div>
Coffee Partial
app/views/coffees/_coffee.html.erb
Controller
app/controllers/favourites_controller.rb
class FavouritesController < ApplicationController
def index
@favourites = current_user.favourite_coffees
end
def create
@favourite = Favourite.create(user: current_user, coffee_id: params[:coffee_id])
@coffee = @favourite.coffee
respond_to do |format|
format.turbo_stream
end
end
def destroy
@favourite = Favourite.find_by(user: current_user, coffee_id: params[:coffee_id])
@coffee = @favourite.coffee
@favourite.destroy
respond_to do |format|
format.turbo_stream
end
end
end
Create Turbo Stream
app/views/favourites/create.turbo_stream.erb
<%= turbo_stream.replace(
"favourite_toggle_#{dom_id(@coffee)}",
partial: 'favourite_toggle',
locals: { coffee: @coffee }) %>
<%= turbo_stream.update(
"favourites_count",
html: %(#{current_user.favourites.count}) )%>
<%= turbo_stream.prepend(
'dashboard_favourites',
partial: 'favourite',
locals: { coffee: @coffee }) %>
Destroy Turbo Stream
app/views/favourites/destroy.turbo_stream.erb
<%= turbo_stream.remove("dashboard_favourites_#{dom_id(@coffee)}") %>
<%= turbo_stream.replace(
"favourite_toggle_#{dom_id(@coffee)}",
partial: "favourite_toggle",
locals: { coffee: @coffee }) %>
<%= turbo_stream.update(
"favourites_count",
html: %(#{current_user.favourites.count}) ) %>
Explanation
We can see that the controller just responds with a turbo stream format.turbo_stream
. It then looks for
a turbo stream with a matching name in the correct view folder.
respond_to do |format|
format.turbo_stream
end
These responses do several actions. While you can respond with multiple streams directly from the controller, it’s considered better practice to move these multi response streams to a partial.
Toggle Buttons Again
These responses do several actions. While you can respond with multiple streams directly from the controller, it’s considered better practice to move these multi response streams to a partial.
<%= turbo_stream.replace(
"favourite_toggle_#{dom_id(@coffee)}",
partial: "favourite_toggle",
locals: { coffee: @coffee }) %>
The next thing that these responses do is a prepend or remove action. So this either adds a partial or removes it from the DOM.
Content Updates
Looking at the create action first; this targets the dashboard_favourites
ID. The favourite
partial is prepended (added inside the target element before the rest of the HTML), and the
@coffee
local variable is passed through to that partial.
Turbo Stream
<%= turbo_stream.prepend(
'dashboard_favourites',
partial: 'favourite',
locals: { coffee: @coffee }) %>
Target Element
<div id="dashboard_favourites" class="shelf row">
<%# New partial is inserted here %>
<% @favourites.each do |coffee| %>
<%= render 'favourites/favourite', coffee: coffee %>
<% end %>
</div>
The destroy content update is simple, it looks for the first element in the DOM with the specified ID, in this case
dashboard_favourites_#{dom_id(@coffee)}
, and removes it.
The final thing that these responses do is update live counters
Live Counters
As part of the Turbo Stream response from the controller we have an “update” action.
<%= turbo_stream.update(
"favourites_count",
html: %(#{current_user.favourites.count}) ) %>
The “Replace” action, as per the Turbo Handbook:
The contents of this template will replace the contents of the element with ID "unread_count" by setting innerHtml to "" and then switching in the template contents. Any handlers bound to the element "unread_count" would be retained. This is to be contrasted with the "replace" action above, where that action would necessitate the rebuilding of handlers.
So it targets the element with ID favourites_count
and swaps the HTML.
<span id="favourites_count"><%= current_user.favourites.count %></span>
The new HTML is the same as the old, but by reloading it the erb is executed again and the counter is updated.
That's it for now. More coming soon. Feedback, thoughts and corrections get in touch