3
.
06
.
2024
3
.
06
.
2024
Hotwire
Ruby on Rails
Software
Tutorial

Smooth Concurrent Updates with Hotwire Stimulus

Michał Łęcicki
Ruby Developer

In the previous blog post, we learned how to create a simple progress bar with Hotwire Turbo and broadcast updates to the frontend. But sometimes, simple solutions aren't enough. It's time to get familiar with another part of Hotwire: Stimulus! In this article, I'll demonstrate using Stimulus to handle more complex frontend logic.

The problem of async updates

Displaying the progress of synchronous data updates is straightforward with Hotwire Turbo. But a more real-life scenario considers asynchronous changes on the backend. In the context of our dummy jokes application, we can introduce more than one job that downloads jokes. When the number of jokes to fetch is more than 25, the process is split into more jobs.

Unfortunately, it causes some issues:

Architecture

Before we go to the code, let's revise the implementation plan. We can summarize the previous architecture with a simple graph: updating the data on the server results in an immediate broadcast and update of the frontend page.

The new architecture must handle async updates. We can't update the HTML directly from the background jobs because of overriding. We need to introduce a "mediator" between broadcasted updates and displaying them on the frontend. Time to use Stimulus. The plan is to:

  1. broadcast updates from jobs
  2. intercept the 'Append new joke' action in the Stimulus controller
  3. apply frontend updates with JavaScript. We will update the progress bar (and other elements) and then execute default behavior (add new joke elements) as before.

The code

The provided code snippets highlight the most significant changes. Check out a complete solution in this repository.

Update the job/service in your Ruby on Rails application: make just one broadcast that amends new jokes.

# app/services/fetch_jokes_service.rb

class FetchJokesService
  def perform(missing_jokes_count)
    jokes = []

    missing_jokes_count.times do |num|
      # logic for fetching/creating jokes same as before
      # (...)

      add_joke_card(joke)
    end

    true
  end

 # now this is the only broadcast from the service
  def add_joke_card(joke)
    Turbo::StreamsChannel.broadcast_append_to(
      [ jokes_request, "jokes" ],
      target: "jokes_grid",
      partial: "jokes/joke",
      locals: { joke: joke }
    )
  end

  # rest of service code
  # (..)
end

Prepare HTML page to work with Stimulus:

  • on the parent div define the controller name: data-controller="progress-bar"
  • define targets following Stimulus convention, eg. data-progress-bar-target="jokesGrid" is translated into jokesGridTarget inside a controller
  • add attributes with input values needed for controller, eg: data-progress-bar-limit-value="<%= @jokes_request.amount %>".
<!-- app/views/jokes_requests/show.html.erb -->

<!-- just one stream is needed now -->
<%= turbo_stream_from @jokes_request, "jokes" %>

<div id="jokes_show"
     data-controller="progress-bar"
     data-progress-bar-limit-value="<%= @jokes_request.amount %>"
     data-progress-bar-actual-value="<%= @jokes_request.jokes.size %>">

  <!-- rest of the html  -->
  <!-- (...)  -->
  <%= render "jokes_progress_bar", actual: @jokes_request.jokes.size, 
                                   limit: @jokes_request.amount %>

  <%= turbo_frame_tag "jokes" do %>
    <div id='jokes_grid'
        data-progress-bar-target="jokesGrid"
        class="grid grid-cols-3 gap-4 mt-4">
      <% @jokes.each do |joke| %>
        <%= render 'jokes/joke', joke: joke %>
      <% end %>
    </div>
    <% if @jokes_request.jokes.size > Joke::PER_PAGE %>
      <%= render "jokes_pagination", pagy: @pagy %>
    <% end %>
  <% end %>

Add the most important part: Stimulus controller. Lots of stuff happening here:

  • define values and targets corresponding to HTML page elements
  • add a new EventListener hook to override rendering turbo stream event: make additional updates on the page and then execute the default behavior. The same approach we would use for adding custom actions to the Stimulus controller.
  • configure functions to run when a new joke is added: update count, update the progress bar.

Huge thanks to Cezary Kłos, who proposed this solution!

// app/javascript/controllers/progress_bar_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="progress-bar"
export default class extends Controller {
  static values = {
    limit: 0,
    actual: 0,
  }
  static targets = ["progress", "count"]
  connect() {
    addEventListener("turbo:before-stream-render", ((event) => {
      const fallbackToDefaultActions = event.detail.render

      event.detail.render = (streamElement) => {
        if (streamElement.action === "append" && streamElement.target === "jokes_grid") {
          this.increment()
        }
        fallbackToDefaultActions(streamElement)
      }
    }))
  }

  increment() {
    this.actualValue++
    this.updateProgress()
    this.updateCount()
  }

  updateProgress() {
    let progress = (this.actualValue / this.limitValue) * 100
    this.progressTarget.style.width = `${progress}%`
  }

  updateCount() {
    this.countTarget.innerText = `${this.actualValue} / ${this.limitValue}`
  }
}

In conclusion, the responsibility for displaying updates shifts from the backend to the frontend. The backend only signals a change, but the Stimulus controller decides what and how to display. The new solution uses JavaScript and is far more flexible than before.

Summary

Hotwire Turbo is perfect for transmitting backend updates to the frontend. But for more complex use cases, we need to use another element of Hotwire: Stimulus. The presented solution is an example of manipulating DOM elements with minimal JavaScript. Hopefully, it inspires you in your Hotwire journey!

P. S. An alternative approach with MutationObserver is presented in this Drifting Ruby podcast.

Michał Łęcicki
Ruby Developer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

Covering indexes - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby on Rails
Postgresql
Backend
Ula Sołogub - SQL Injection in Ruby on Rails

The Deadly Sins in RoR security - SQL Injection

14
.
11
.
2023
Urszula Sołogub
Backend
Ruby on Rails
Software
Michal - Highlights from Ruby Unconf 2024

Highlights from Ruby Unconf 2024

14
.
11
.
2023
Michał Łęcicki
Conferences
Visuality
Cezary Kłos - Optimizing Cloud Infrastructure by $40 000 Annually

Optimizing Cloud Infrastructure by $40 000 Annually

14
.
11
.
2023
Cezary Kłos
Backend
Ruby on Rails

Smooth Concurrent Updates with Hotwire Stimulus

14
.
11
.
2023
Michał Łęcicki
Hotwire
Ruby on Rails
Software
Tutorial

Freelancers vs Software house

14
.
11
.
2023
Michał Krochecki
Visuality
Business

Table partitioning in Rails, part 2 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
Ruby on Rails

N+1 in Ruby on Rails

14
.
11
.
2023
Katarzyna Melon-Markowska
Ruby on Rails
Ruby
Backend

Turbo Streams and current user

29
.
11
.
2023
Mateusz Bilski
Hotwire
Ruby on Rails
Backend
Frontend

Showing progress of background jobs with Turbo

14
.
11
.
2023
Michał Łęcicki
Ruby on Rails
Ruby
Hotwire
Frontend
Backend

Table partitioning in Rails, part 1 - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend
Ruby on Rails

Table partitioning types - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Backend

Indexing partitioned table - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Backend
Postgresql
SQL Views in Ruby on Rails

SQL views in Ruby on Rails

14
.
11
.
2023
Jan Grela
Backend
Ruby
Ruby on Rails
Postgresql
Design your bathroom in React

Design your bathroom in React

14
.
11
.
2023
Bartosz Bazański
Frontend
React
Lazy Attributes in Ruby - Krzysztof Wawer

Lazy attributes in Ruby

14
.
11
.
2023
Krzysztof Wawer
Ruby
Software

Exporting CSV files using COPY - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Postgresql
Ruby
Ruby on Rails
Michał Łęcicki - From Celluloid to Concurrent Ruby

From Celluloid to Concurrent Ruby: Practical Examples Of Multithreading Calls

14
.
11
.
2023
Michał Łęcicki
Backend
Ruby
Ruby on Rails
Software

Super Slide Me - Game Written in React

14
.
11
.
2023
Antoni Smoliński
Frontend
React
Jarek Kowalewski - ILIKE vs LIKE/LOWER - Postgres Stories

ILIKE vs LIKE/LOWER - Postgres Stories

14
.
11
.
2023
Jarosław Kowalewski
Ruby
Ruby on Rails
Postgresql

A look back at Friendly.rb 2023

14
.
11
.
2023
Cezary Kłos
Conferences
Ruby

Debugging Rails - Ruby Junior Chronicles

14
.
11
.
2023
Piotr Witek
Ruby on Rails
Backend
Tutorial

GraphQL in Ruby on Rails: How to Extend Connections

14
.
11
.
2023
Cezary Kłos
Ruby on Rails
GraphQL
Backend
Tutorial

Tetris on Rails

17
.
03
.
2024
Paweł Strzałkowski
Ruby on Rails
Backend
Frontend
Hotwire