ActionCable is a great framework included in Rails that adds interactivity with WebSockets, and Stimulus.js gives you the ability to write concise sprinkles, and easily hook them into particular pages.
Where to begin?
Start with the ActionCable sample app provided by Rails. You can clone actioncable-examples, and follow the setup instructions in the README. You should get everything working, including installing the required dependencies, before you start making changes to the Javascript. This way, you will have a consistent starting point for the tutorial.
You can update the ruby version, rails, and a few of the libraries, and add in Webpacker and Stimulus.js, and the actioncable library from NPM. I tried to change as little of the ruby code as possible, in an effort to highlight how easy it can be to refactor existing front end Javascript to use Stimulus.
This Evil Martians article can help with setting up ActionCable in webpacker.
Adding the Controller
Each message is going to have a Stimulus controller associated with it. This means that as the controller is loaded, it will need to set up the ActionCable connection, and then handle incoming messages and append the new comment. You will set up a comments target where the controller will add the incoming comments. The Stimulus controller’s life cycle will help handle listening on a particular message’s comments, and unfollowing that message when the controller is disconnected.
The HTML
You’ll need to update app/views/messages/show.html.erb
with the necessary controller and message data:
<div data-controller="messages" data-messages-id="<%= @message.id %>">
<h1><%= @message.title %></h1>
<p><%= @message.content %></p>
<%= render 'comments/comments', message: @message %>
</div>
In the comments partial, app/views/comments/_comments.html.erb
, add the target annotation:
<section id="comments"
data-channel="comments"
data-message-id="<%= message.id %>"
data-target="messages.comments">
The Controller
The messages_controller.js
handles setting up the ActionCable channel initially, and connecting to the channel every time the commentator visit a different message page.
First import the required dependencies:
import { Controller } from "stimulus"
import createChannel from "cables/cable";
Then, set up the comments target:
export default class extends Controller {
static targets = [ "comments" ]
The controller’s initialize()
method is going to set up the ActionCable channel. connected()
in the ActionCable subscription will call the controller’s listen
function, which connects the message on the page with future comments that are pushed up to the page. received()
handles data from the websocket. It creates a DOM element from the string sent over the wire, and verifies that the user of the comment is not the user who just posted the comment before appending the it. It might make more sense to send the user id as a separate value as well, but it’s not necessary with DomParser.
initialize() {
let commentsController = this;
this.commentsChannel = createChannel( "CommentsChannel", {
connected() {
commentsController.listen()
},
received(data) {
let html = new DOMParser().parseFromString( data['comment'] , 'text/html');
const commentHTML = html.body.firstChild;
if (getCurrentUserId() != commentHTML.getAttribute('data-user-id')) {
commentsController.commentsTarget.insertAdjacentElement('beforeend', commentHTML );
}
}
});
}
The controller’s connect()
method also calls listen()
. The need for two different listen()
calls has to do with a condition where on a page refresh, the ActionCable connection isn’t done loading by the time the controller’s connect() function is called. But on subsequent page loads using Turbolinks, the ActionCable subscription is technically still connected, so ActionCable won’t call it’s version of connect()
again, and the controller will call listen()
.
connect() {
this.listen()
}
When the controller is removed from the page, likely because the commentator is moving to another page, the controller stops following the message’s comments:
disconnect() {
this.commentsChannel.perform('unfollow')
}
Here is the listen()
function. It calls perform on the ActionCable subscription, which is sent over the socket to our ruby server. Stimulus’ data attributes feature get the message id easily.
listen() {
if (this.commentsChannel) {
this.commentsChannel.perform('follow', { message_id: this.data.get('id') } )
}
}
}
This function gets the current user’s id that is stored in the head
of the page. This is based off of another post, Where do I store my state in Stimulus?
function getCurrentUserId() {
const element = document.head.querySelector(`meta[name="current-user"]`)
return element.getAttribute("id")
}
Conclusion
I hope this helps you see how Stimulus can interact with other Javascript components in our app. ActionCable neatly hides a lot of the complexities of WebSockets, and Stimulus neatly integrates with ActionCable to manage connecting and disconnecting to different channels when we need them. This will help us by cutting down on extra data being sent over the wire when our app doesn’t need it.
All the code can be found here on Github: https://github.com/johnbeatty/actioncable-examples
Feel free to leave a comment or question below.
Want To Learn More?
Try out some more of my Stimulus.js Tutorials.
12 comments on “Grabbing ActionCable with Stimulus.js”
[…] Grabbing ActionCable with Stimulus.js […]
[…] Subscribing to many channels in ActionCable • John Beatty on Grabbing ActionCable with Stimulus.js […]
Your tutorials on Stimulus have been absolutely fantastic! Thank you for taking the time to create and share these. They have helped me tremendously.
I’m revisiting ActionCable after not touching it for a while and I’ve stumbled a little with setting it up with Webpacker and Stimulus (which I already have working in my app).
I assume initially we will be doubling up a subscription/following of the comments channel? I say this as the `def follow(data)` method will be called once the connection is authenticated, and then when we connect to our Stimulus controller we call `this.commentsChannel.perform(‘follow’` from both `initialize` and `connect` which would follow again if I’m not mistaken.
Another problem I’m having is `received` is being called multiple times. I believe this is because `createChannel( “CommentsChannel”` is being called every time I navigate to a page that has `data-controller=”messages”`, whereas, with the old approach of using Sprockets (as used in the official Rails docs), `App.cable.subscriptions.create { channel: “CommentsChannel”` is only called once.
I believe you are referring to improving upon this when you say:
>Stimulus neatly integrates with ActionCable to manage connecting and disconnecting to different channels when we need them. This will help us by cutting down on extra data being sent over the wire when our app doesn’t need it.
I recently found another article (https://mentalized.net/journal/2018/05/18/getting-realtime-with-rails/) that creates the subscription/follower once in `app/javascripts/packs/application.js`. I believe this will prevent my problem of `received` being called multiple times.
There won’t be any doubling up of the subscription.
If the page is completely refreshed, or the ActionCable connection has been setup, the “follow” command from
connect()
will fire before the ActionCable connection is setup, and won’t reach the application server. “follow” therefore needs to be called when the ActionCable connection is running.If the page is visited when the ActionCable connection has already been initialized and set up, the “follow” command from
connect()
will reach the application server.If you’re getting multiple responses back over the Websocket, you can look into stopping all streams on the the ruby side of the ActionCable channel.
Thanks so much for taking the time to reply.
I believe a big part of my problem is we are creating a new subscription each time we load a page with `data-controller=”messages”`
This will cause the code in `cable.js` to be hit and create a new subscription on each page that contains `data-controller=”messages”`
To get around this, I had to add some extra code to cable.js to check whether a subscription already exits:
“`
const currentSubscription = consumer.subscriptions.subscriptions.find(subscription => subscription.identifier.includes(“CommentsChannel”))
if (currentSubscription)
return currentSubscription
“`
Therefore a new subscription is not created if it is not needed.
Before this addition, each page that I clicked on created a new subscription:
Home page -> 1 subscription created
Second page -> 2 subscriptions in total now created
Third page -> 3 subscriptions in total now created
In this example, after visiting 3 pages, when Broadcast was called, `received` would then fire 3 times.
Also, while I’m figuring this all out, I have disabled Turbolinks caching and this prevents `disconnect()` being called twice when I navigate away from a page.
In addition to my above comment, I believe one of the key differences was in my code I was not accepting a parameter to stream from and instead was using the `current_user.id`:
“`rb
def follow
stop_all_streams
stream_from “messages:#{current_user.id}”
end
“`
whereas, in your example you are sending the message_id as a parameter and therefore you are subscribing to a new channel each time:
“`rb
def follow(data)
stop_all_streams
stream_from “messages:#{data[‘message_id’].to_i}:comments”
end
“`
I believe that’s why I needed my fix in the comment above to not create a new subscription if it already existed. In my code, a subscription may already exist with the user_id, in your code a subscription would not already exist with the message_id.
Ah, that makes a lot of sense. I’m glad it’s working for you!
I’m trying to clarify some best practices for Stimulus and JS in general.
Is there benefit to using `function getCurrentUserId()` vs creating that function as a method in our class which we would access with `this.getCurrentUserId()`?
I don’t believe there is. I copied that pattern from this discourse post about getting other meta values:
https://discourse.stimulusjs.org/t/csrf-token-invalidauthenticitytoken/91/3
I think this pattern might be best, since it’s more like a static function. I also bet you could encapsulate it into a library and just import the function when you need it.
You know when you’re starting a project and you realize it’s going to be a bit tricky, so you Google for exactly what you need at the start just to get it over with?
Well this beautiful article was exactly what I was looking for! Very slick implementation and really easy to follow.
Thank you!
[…] will reference the Grabbing ActionCable with Stimulus.js […]
What is the purpose of the listen() function within the Stimulus controller? How does it contribute to the interaction between the ActionCable channel and the incoming comments, especially when considering different scenarios like page refresh and subsequent page loads using Turbolinks?