Seiichi Yonezawa

How to write new ActionCable

· nzwsch

Challenges in DOM Manipulation for Progress Bar Implementation

With the advent of Turbo in Rails 7, the need to write JavaScript or use ActionCable has decreased significantly.

However, it’s still difficult to cover every behavior using only Turbo.

For example, when implementing a progress bar, Turbo rewrites the entire DOM, which feels somewhat clunky. By modifying the style.width, smoother animations can be achieved.

When creating a ProgressChannel, the following code is generated:

import consumer from "channels/consumer"

consumer.subscriptions.create("ProgressChannel", {
  connected() {
    // Called when the subscription is ready for use on the server
  },

  disconnected() {
    // Called when the subscription has been terminated by the server
  },

  received(data) {
    // Called when there's incoming data on the websocket for this channel
  }
});

Examples of ActionCable implementations are rare, but in the Full-Stack Examples, the DOM seems to be updated directly.

Even in the provided example, adding DOM manipulation logic to methods like received makes the class cumbersome. It might be more efficient to use React than to handle such logic here.

Now that Stimulus is available, is there a better way to write this without relying on jQuery or React?

Examples and Challenges in ActionCable Implementation

Let’s start by creating a ProgressController. Instead of connecting to the ProgressChannel from every page, it might be better to limit consumer.subscriptions.create() to only execute when a specific DOM element (e.g., <progress>) is present.

import { Controller } from "@hotwired/stimulus"

import consumer from "channels/consumer"

// Connects to data-controller="progress"
export default class extends Controller {
  connect() {
    this.subscription = consumer.subscriptions.create("ProgressChannel", {
      received: this.handleReceived.bind(this)
    })
  }

  handleReceived(data) {
    this.element.style.width = `${data.progress}%`
  }
}

This code might seem like a reasonable solution. However, even if controlled with conditional statements, multiple tags with data-controller="progress" could cause duplicate connections.

To address this, we could change the approach so that Stimulus responds when ProgressChannel calls received.

Fortunately, there’s a constructor called CustomEvent that allows us to create custom events for Stimulus to respond to.

import consumer from "channels/consumer"

consumer.subscriptions.create("ProgressChannel", {
  received(data) {
    const event = new CustomEvent("progress", {
      detail: {
        progress: data["progress"]
      }
    })
    window.dispatchEvent(event)
  }
});

Now, when ProgressChannel receives data, a custom event called progress is dispatched. This ensures no additional code is needed on the channel side.

Efficient Implementation Using Stimulus

Next, let’s look at the Stimulus code:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="progress"
export default class extends Controller {
  connect() {
    window.addEventListener("progress", this.onProgress)
  }

  disconnect() {
    window.removeEventListener("progress", this.onProgress)
  }

  onProgress(event) {
    this.element.style.width = `${event.detail.progress}%`
  }
}

This is a cleaner solution compared to the initial code. However, if you test it, you’ll find that the function isn’t bound to the correct context, making it impossible to access this.element.

To bind the function, you would need to store this.onProgress.bind(this) in a variable, which could complicate the code.

Referring to Communicating between Stimulus controllers using custom events, we can simplify this further:

<div data-action="progress@window->progress#onProgress">

This can be interpreted as custom-event@window->controller#method.

Implementing an Intuitive Progress Bar

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="progress"
export default class extends Controller {
  onProgress(event) {
    this.element.style.width = `${event.detail.progress}%`
  }
}

With this approach, we resolved the binding issue and eliminated the need for connect and disconnect methods.

Over the years, I’ve implemented progress bars using combinations of SSE, React’s useEffect with setTimeout, WebWorkers, and more. However, I found this combination of ActionCable and Stimulus to be incredibly intuitive.

While the actual code I used is more complex, this approach seems promising for implementing interactive pages in a way that’s distinct from React.

By mastering the division of roles—Turbo for basic rendering, ActionCable and Stimulus for partial updates—it’s possible to create highly interactive applications effectively.