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:

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.

gif of toggling subscription button

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 }) %>
gif toggle favourite button
gif toggle shelf button

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>
gif showing content being added and removed with turbo streams

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.

gif showing content being removed with turbo streams

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