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
//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"})
},
})
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
irb(main):001:0> Turbo::StreamsChannel.action_methods
=> #<Set: {"subscribed", "verified_stream_name_from_params"}>
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
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>
//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 })
}
})
}
}
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)