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!
@dhh FYI the `channel_for` class method is missing 'def'; guess it's a slide-typo. pic.twitter.com/BLmbt9LJfY
— Mike (@bsodmike) April 23, 2015
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.
I suppose @action('do_something', { foo: 'bar' } would be available on the handler, serialized to a Hash? @DHH #ActionCable #rails
— Mike (@bsodmike) April 25, 2015
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.