Michael de Silva's Blog

Software Engineer. Rubyist and Roboticist.

Michael de Silva's Blog

Software Engineer. Rubyist and Roboticist.

Rails 5: Action Cable Demystified

This write up is based on @DHH's keynote on Rails 5 at RailsConf 2015 and I am providing snippets of code directly from his slides, apart from fixing one typo!

We start by creating a channel, where its related to the DOM element, by using data-behavior. It's also been namespaced under App.

Once the inbox Channel is instantiated, there's a call-back, the data received, which we've handled to update a simple count.

class App.inbox extends ActionCable.Channel
  channelName: 'inbox'

    element: -> $('[data-behavior~=inbox]')

    received: (data) =>
        @element().text data['count']

$ ->
    App.inbox = new App.Inbox

As for the corresponding channel on the server side, it creates a connection, by calling connect; here is simply subscribes to a simple Redis pub/sub channel.

class InboxChannel < ActionCable::Channel::Base
  def connect
    subscribe_to Inbox.channel_for(@current_user)
  end
end

The Inbox is responsible for both being the broadcaster but is also for maintaining the state of the inbox count.

Within your app when effectively calling Inbox.new(@current_user).increment for a specific user — it will increment the count in Redis, ActionCable will broadcast this value back out, which is going to hit receive on the JS side, and update the text in the DOM element.

class Inbox
  attr_accessor :user, :broadcaster
  delegate :broadcast, to: :broadcaster

  def self.channel_for(user) "inbox_#{user.id}" end

  def initialize(user)
    @user = user
    @broadcaster = ApplicationCable.broadcaster_for(
      self.class.channel_for(@current_user)
    )
  end

  def increment
    broadcast \
    count: redis.incr("inbox/count/#{user.id}")
  end
end

Calling a method on the Channel

Let's extend it to send something back, like clear the count. On the JS side, we have a method clear, which we can send back. However, do note that we are not sending any data back — simply triggering this method on the Channel; this works perfectly for an implicit action such as clear.

class App.inbox extends ActionCable.Channel
    ...

    received: (data) =>
        @element().text data['count']

    clear: =>
        @action 'clear'
$ ->
    App.inbox = new App.Inbox

    $(document).on 'click', '[data-behavior~=clear_inbox]', ->
        App.inbox.clear()
        false

This calls @action 'clear', which is a remote procedure call that's going to call back to InboxChannel. Notice how InboxChannel#connect has been cleaned up instantiate the encapsulated Inbox class.

class InboxChannel < ActionCable::Channel::Base
  def connect
    @inbox = Inbox.new(@current_user)
    subscribe_to @inbox.channel
  end

  def clear
    @inbox.clear
  end
end

Notice @inbox.clear above, which simply sets the value in Redis to 0 and broadcasts this out.

class Inbox
  ...
  def clear
    redis.set "inbox/count/#{user.id}", 0
    broadcast count: 0
  end
end

On the client side, we aren't maintaining state there. The state is based on the pub/sub channel, and therefore if the page is loaded in multiple-tabs, the inbox 'count' would be reset across all of them. And the beauty of this is that we aren't limited to sending simple values, but entire partials as well, which would always be 'presented' on the DOM el.

Sending explicit data to the Channel, for Broadcasting

What if we wanted to manually trigger a refresh of the inbox count? — Due to the pub/sub mechanism, we really wouldn't need to do this, but it's a contrived example at best, so bear with me please — Well, I tweeted how I thought this worked to @DHH, who very kindly confirmed it.

Within our click handler, we are going to call refresh, and notice that unlike clear, it now has an argument. Let's send some JSON back

class App.inbox extends ActionCable.Channel
    ...

    clear: =>
        @action 'clear'

    refresh: (data) =>
        @action 'refresh', data.last_checked
$ ->
    App.inbox = new App.Inbox

    $('[data-behavior~=refresh-inbox]').on 'click', ->
        App.inbox.refresh({
            // TODO: Needs to be set via `App.inbox.received`
            last_checked: $(@).attr('data-last-checked')
        })
        false

Notice that we've passed the JS object to @action, and we'll simply need to intercept this on the server side, and then broadcast it.

class Inbox
  ...
  def refresh(checked_at)
    if (Time.zone.parse(checked_at) > 5.minutes.ago)
        broadcast \
            count: redis.get("inbox/count/#{user.id}")
    end
  end
end

This contrived example, prevents the user from sending multiple refresh requests (or could be used for any other input based action); although, ideally, we'd be better of checking the checked_at time at the JS level, before even sending anything down the channel. As I said, it wasn't a great example, but it does show how @action() could be used to send data back to the server.

comments powered by Disqus