Skip to content →

Grabbing ActionCable with Stimulus.js

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

Comments or Questions? Find me on twitter @jpbeatty

Want To Learn More?

Try out some more of my Stimulus.js Tutorials.

Published in ruby on rails Stimulus JS Tutorial

9 Comments

  1. Mark Mark

    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.

    • johnbeatty johnbeatty

      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.

      • Mark Mark

        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.

        • Mark Mark

          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.

          • johnbeatty johnbeatty

            Ah, that makes a lot of sense. I’m glad it’s working for you!

  2. Mark Mark

    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()`?

Leave a Reply

Your email address will not be published. Required fields are marked *