CallMarx.dev

Tutorial: Rails7, Tailwind, and Hotwire - Part 2

19-12-2021 11 minutes reading.
Hotwire Turbo Logo

In the previous part of this tutorial, I explained how to customize and use Tailwind without a single line of CSS and JavaScript. Now, I’ll discuss a bit about the Hotwire Turbo package.

Overall Objective

The goal is to develop (and learn) using Rails 7, esbuild, Tailwind, and Hotwire (Turbo and Stimulus), but my focus will mainly be on the Hotwire package and how it can help us. As I progress through the studies and implementation, I’ll continue to add to this tutorial. For now, we have:

The backdrop is a Kanban-style application, featuring a board where we can include, view, edit, and delete cards/tasks, with simultaneous persistence via websockets for all open sessions of the application. All code is available in this repository. Note that it includes some branches that represent the parts covered here.

Step 2 - Hotwire Turbo

In this step, I explain the purpose of this tool and implement the render mode turbo_stream along with broadcast via ActionCable. The result of this step is divided into two parts: the branch blog-part-2.1, where I use only turbo_stream without broadcast, and the final one with broadcast in the branch blog-part-2.2.

Conceptually: Turbo what?

Consulting the introduction of the Handbook,we have:

Bundles a set of tools for creating fast, modern, progressively enhanced web applications with minimal JavaScript.

Perhaps the concept of Progressive Enhancement does not sound very clear. Created in 2003, it is basically a design strategy, a web architecture that emphasizes the progressive loading of the page, prioritizing the main content (HTML) and distributing the others (CSS, JavaScript, additional HTML, etc.) in other presentation/loading layers.

Continuing on the guidelines of Hotwire Turbo, we have:

Offers a simpler alternative against the dominance of client-side frameworks, which put all the logic on the front-end, confining the server-side of the application to little more than a JSON API.

This is the passage that caught my attention the most, captivated me, and motivated me to study and want to use this. The “dominance of client-side frameworks” is something that has been drawing my attention in recent years, it seems that suddenly the entire back-end area has been “confined” to the JSON API. However, with all the dynamism that a web page needs nowadays, like data processing according to user behavior and navigation, this ended up being done on the client-side, front-end, via JavaScript with libraries like jQuery. Since certain logic of this processing must be protected on the back-end, not being completely exposed on the front-end, meaning it cannot be entirely done on the client-side of the application, we eventually face “mirroring the logic on both sides. And the guidelines continue exactly in this direction:

With Turbo, you allow the server to deliver HTML directly (…). You no longer deal with mirroring the logic on both sides of the application, permeated via JSON. All logic resides on the server, and the browser only handles the final HTML.

This is the concept of HTML-Over-The-Wire - Hotwire. 🤓

Turbo Frame VS. Turbo Stream

Something that confused me quite a bit at first was understanding the difference between Turbo Frame and Turbo Stream, as they are different approaches. Fortunately, I found this table published by The Pragmatic Programmers:

----------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
                Turbo Frames                                                |                     Turbo Streams                                                               |
----------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+
A response can only change one DOM element.                                 |  A response can change an arbitrary number of DOM elements.                                     |
A response will update the inner HTML of the element.                       |  A response can append, prepend, remove, replace, or update the affected elements.              |
The affected element must be a turbo-frame with the same DOM ID.            |  Affected elements can be any type of HTML with a matching DOM ID to the target of the stream.  |
Turbo Frames are evaluated on any navigation inside a turbo-frame element.  |  Turbo Streams are evaluated on form responses or ActionCable broadcasts.                       |
----------------------------------------------------------------------------+-------------------------------------------------------------------------------------------------+

Note: I don’t intend to use Turbo Frames in this project. I might reconsider this to take advantage of the lazily load functionality, but if that happens, I’ll add a separate post about it.

Starting with Turbo Stream

To separately explain the render mode turbo_stream, I included the code for this subpart in the branch blog-part-2.1.

In the ChoresController that we generated with rails generate scaffold in the previous step of this tutorial, Rails included multiple rendering formats by default, in this case, HTML and JSON. Since we included gem "turbo-rails" in the Gemfile, we also have access to rendering via Turbo Stream, just by adding format.turbo_stream inside the respond_to do |format| block. Doing this for the create and destroy methods, we have in app/controllers/chores_controller.rb:

# file app/controllers/chores_controller.rb of blog-part-2.1 branch
class ChoresController < ApplicationController
  ...
  def create
    ...
    respond_to do |format|
      if @chore.save
        format.turbo_stream # include this
        format.html { redirect_to @chore, notice: "Chore was successfully created." }
        format.json { render :show, status: :created, location: @chore }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @chore.errors, status: :unprocessable_entity }
      end
    end
  end
  ...
  def destroy
    @chore.destroy
    respond_to do |format|
      format.turbo_stream # include this
      format.html { redirect_to chores_url, notice: "Chore was successfully destroyed." }
      format.json { head :no_content }
    end
  end
  ...
end

For this type of rendering, we also need dedicated files in app/views, just like we have for HTML and JSON. Thus, we have the following app/views/chores/create.turbo_stream.erb:

<!-- file app/views/chores/create.turbo_stream.erb of blog-part-2.1 branch -->
<%= turbo_stream.append "chores", partial: "chores/chore", locals: { chore: @chore } %>
<%= turbo_stream.replace "chore_form", partial: "chores/form", locals: { chore: Chore.new } %>

Since we want to see this application without reloading the entire page, that is, to be able to render only a part of the page, we will do this in the index of chores. Therefore, we use the methods turbo_stream.append and turbo_stream.replace above, pointing to the DOM ID of the page. In other words, the first arguments "chores" and "chore_form" respectively must be present on the fully rendered page by app/views/chores/index.html.erb. Thus, we need to include id="chores" in the <div> that wraps the listing of chores:

<!-- file app/views/chores/index.html.erb  of blog-part-2.1 branch -->
<div ...>
  <div ...>
    <div id="chores" ...> <!-- include id="chores" for turbo_stream.append -->
      <% @chores.each do |chore| %>
        <%= render "chore", chore:chore %>
      <% end %>
    </div>
  </div>
  <div ...>               <!-- don't include id="chore_form" here!         -->
    <%= render "form", chore: @chore %>
  </div>
</div>

Now, for id="chore_form", we can’t include it in the <div> that wraps <%= render "form", chore: @chore %> because the turbo_stream.replace method completely replaces the element. Since we are replacing it with the partial view app/views/chores/_form.html.erb, this <div> would be deleted. Therefore, we should include id="chore_form" in the form file itself:

<!-- file app/views/chores/_form.html.erb  of blog-part-2.1 branch -->
<%= form_with(model: chore, id: "chore_form") do |form| %>
  ...
<% end %>

Now, to delete a chore is even simpler. We have the following app/views/chores/destroy.turbo_stream.erb:

<!-- file app/views/chores/destroy.turbo_stream.erb of blog-part-2.1 branch -->
<%= turbo_stream.remove dom_id(@chore) %>

Similarly, we need to include the DOM ID, but in this case specific to each chore, hence dom_id(@chore). Additionally, we also need to edit the <button> for the delete icon to point to the destroy method of the controller. Therefore, in app/views/chores/_chore.html.erb:

<!-- file app/views/chores/_chore.html.erb of blog-part-2.1 branch -->
<div id="<%= dom_id(chore) %>" ...> <!-- include dom_id(chore) for turbo_stream.remove -->
  <div ...>
    ...
    <div ...>
      ...
      <%= button_to chore_path(chore), method: :delete, class: ... do %> <!-- make button point to delete method -->
        <svg ...>
          ...
        </svg>
      <% end %>
      ...

Now, when removing a chore, we also have a partial rendering that removes the HTML of the deleted chore without reloading the entire page. The final result of this subpart, when creating or deleting a chore at http://localhost:3000/chores, looks like this: Turbo-Stream Add

Note: There is no full page reload after each addition or deletion; the page is partially reloaded via fetch.

However, if you open two windows of http://localhost:3000/chores and add or delete chores in one window, you will see that the changes are only reflected in the window where the action was performed; there is no persistence across all sessions. To achieve that, we need to implement it via ActionCable with broadcast.

Applying broadcast

This corresponds to the final part of this tutorial stage. The previous subpart was solely to explain the isolated use of turbo_stream rendering. As the goal is to create a Kanban-style application, I intend to predominantly use this rendering method along with broadcast going forward. The complete code for this stage is in the blog-part-2.2 branch.

To apply changes to chores across all sessions, we’ll utilize Turbo::StreamsChannel, which comes with the turbo-rails gem. This channel can be invoked directly in the model or controller, depending on the preferred approach. The idea here is that we’ll be implementing partial renders in a Publish/Subscribe pattern. Behind the scenes, we’ll use Active Jobs to asynchronously “publish” the turbo_stream partial render and Action Cable to deliver these updates to “subscribers”.

In our case, our “subscribers” are all open sessions of http://localhost:3000/chores, and we can map this using the turbo_stream_from helper. Therefore, in app/views/chores/index.html.erb, we have:

<!-- file app/views/chores/index.html.erb  of blog-part-2.2 branch -->
<%= turbo_stream_from "chores" %>
<div ...>
  ...
</div>

As mentioned earlier, we can include Turbo::StreamsChannel in the model or controller to cover the modifications of create, update, and delete. Specifically, this can be achieved through methods like .broadcast_append_to, .broadcast_replace_to, .broadcast_remove_to, among others. If we include this in the model using Active Record callbacks, such as after_create_commit, every time the model is altered, we will trigger partial rendering “publications”. However, since I currently want to trigger these changes only through user requests, I don’t want rails db:seed, for example, to trigger this. So, I opted to include it in the controller.

Therefore, in app/controllers/chores_controller.rb, we have:

# file app/controllers/chores_controller.rb of blog-part-2.2 branch
class ChoresController < ApplicationController
  before_action :set_chore, only: %i[ show edit update destroy ]
  after_action :broadcast_insert, only: %i[create]
  after_action :broadcast_remove, only: %i[destroy]

  ...

  private
    ...
    def broadcast_insert
      return if @chore.errors.any?
      Turbo::StreamsChannel.broadcast_append_to(
        "chores",
        target: "chores",
        partial: "chores/chore",
        locals: { chore: @chore }
      )
    end

    def broadcast_remove
      return unless @chore.destroyed?
      Turbo::StreamsChannel.broadcast_remove_to(
        "chores",
        target: ActionView::RecordIdentifier.dom_id(@chore)
      )
    end
end

Since the private methods broadcast_insert and broadcast_remove above handle the insertion and removal of chores, we no longer need to do this in the *.turbo_stream.erb views created in the previous subpart. Therefore, for this final part, I have removed the app/views/destroy.turbo_stream.erb view and kept app/views/chores/create.turbo_stream.erb with the following content:

<!-- file app/views/chores/create.turbo_stream.erb of blog-part-2.2 branch -->
<%= turbo_stream.replace "chore_form", partial: "chores/form", locals: { chore: Chore.new } %>

Note: Pay attention! We’re not broadcasting the replace of the form via ActionCable like the other partial renders. I’ve kept it here with turbo_stream for the current session only, which makes total sense since we don’t need to clear the form in other sessions. In fact, doing so could erase a form that another user is currently filling out and hasn’t submitted yet.

Done! Just start the project with bin/dev and access it in more than one window at http://localhost:3000/chores. You should achieve this final result: Turbo-Stream with Broadcast

Nice Michael Scott - gif