DEV Community

haroldus-
haroldus-

Posted on

custom Turbo Streams Channel with client-side initiated actions

How can one use a custom Turbo Streams Channel class and associated methods (as described by the turbo-rails gem) in conjunction with client-side channel actions?

ActionCable background

When implementing an ActionCable channel one thinks of it as the controller for the Websocket: information passes back and forwards from client to server and vice-versa through the filter of the channel.

Methods described in the channel class are termed "actions". As with a controller, these actions imply some action taking place on the client-side: for example, the built-in "subscribed" action occurs once the client's connection is ready to use the Websocket. One can implement and use one's own action like so:

# app/channels/my_channel.rb
class MyChannel < ApplicationCable::Channel

  def subscribed
    ...
  end

  def my_action(data)
    MyJob.perform_later(data)
  end

end
Enter fullscreen mode Exit fullscreen mode
//app/javascript/channels/my_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("MyChannel", {
  connected() {
    // Called when the subscription is ready for use on the server
    this.perform("my_action", {data: "some data"})
  },
})
Enter fullscreen mode Exit fullscreen mode

the Turbo Streams Channel

One can easily set up a Turbo Streams Channel subscription in a view using the <%= turbo_stream_from(...) %> helper. This will create an HTML element in the view that initiates a connection to the Turbo Streams Channel to allow the various HTML-over-the-wire broadcasts. However, from the client perspective, you are limited by the actions defined by the library's class:

class Turbo::StreamsChannel < ActionCable::Channel::Base
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if stream_name = verified_stream_name_from_params
      stream_from stream_name
    else
      reject
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
irb(main):001:0> Turbo::StreamsChannel.action_methods
=> #<Set: {"subscribed", "verified_stream_name_from_params"}>
Enter fullscreen mode Exit fullscreen mode

What if one wanted to use the simplicity of broadcasts from the Turbo Streams Channel and send information back to the server from the client through the Websocket?

Why one might want to do this is to keep the logic of the channel self-contained. For example, an Appearance channel might contain an appear action.

a custom Turbo Streams Channel

To specify a custom channel in the view, one uses the channel option:
<%= turbo_stream_from(..., channel: "MyChannel") %>

The MyChannel class then needs the primitives used by the Turbo Streams Channel:

# app/channels/my_channel.rb
class MyChannel < ApplicationCable::Channel
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    ...
  end

  def my_action(data)
    MyJob.perform_later(data)
  end

end
Enter fullscreen mode Exit fullscreen mode

a Stimulus controller instead of a channel.js subscription

Now the client needs a way to trigger the action on the server. One can't use the ActionCable way of creating a subscription, as above, since turbo-rails already creates this on the client. However, one can follow the advice of the Turbo handbook and use a Stimulus controller acting on the Turbo Streams Channel HTML element to trigger additional behaviour:

<div data-controller="my-action">
  <%= turbo_stream_from(..., channel: "MyChannel", data: { my_action_target: "turboCableStreamSourceElement" }) %>
  <div id="actionables">
    <% @actionables.each do |a| %>
      <%= render(
        partial: "actionables/#{a.actionable_type.underscore.pluralize}/#{actionable.action}",
        locals: {actionable: a},
      )%>
    <% end %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode
//app/javascript/controllers/my_action_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="my-action"
export default class extends Controller {
  static targets = ["turboCableStreamSourceElement", "actionable"]

  async actionableTargetConnected(element) {
    if (this.hasTurboCableStreamSourceElementTarget) {
      await this.waitForConnectedSubscription()
      this.turboCableStreamSourceElementTarget.subscription.perform("my_action", {id: element.dataset.id})
    }
  }

  waitForConnectedSubscription() {
    return new Promise((resolve) => {
      if (this.turboCableStreamSourceElementTarget.hasAttribute("connected")) {
        resolve()
      } else {
        const observer = new MutationObserver((mutationsList) => {
          for (const mutation of mutationsList) {
            if (mutation.type === "attributes" && mutation.attributeName === "connected") {
              observer.disconnect()
              resolve()
              break
            }
          }
        })
        observer.observe(this.turboCableStreamSourceElementTarget, { attributes: true })
      }
    })
  }

}
Enter fullscreen mode Exit fullscreen mode

The above Stimulus controller looks for actionable elements to appear (presumably from a broadcast) and waits for the Turbo Streams HTML element to be connected before performing an action.

next steps

  • The Stimulus controller could be rewritten to be generally reusable if the channel action were available in the DOM.
  • One might also write a Stimulus action for the user to trigger from the UI which in turn triggers a channel action.

Top comments (0)