Getting started with Rails ActionCable (1/2)

An article, posted about 8 years ago filed in ActionCable, rails, ruby, ruby on rails, programming, tutorial, websockets & webapp.

As the lead developer at HeerlijkZoeken.nl I wanted to try the new Rails ActionCable technology for a new feature: shopping lists. The idea is that you can walk in a store or on a market, mark an ingredient as checked when you add it to your (physical) basket and continue shopping. ActionCable can make the experience nicer because it, based on WebSockets, allows for real time notifying other viewers and editors of the same shopping list. No more shouting around in the supermarket: I’ve got the milk! Sure, nothing essential, but I needed an excuse ;)

Getting started with Rails ActionCable (1/2)

(Note that we recently migrated from Rails 4, so not everything was in place in our app, just ignore the bits Rails already made for you; everything has been tested with Rails 5.0.0.1)

Getting the basics right

To start: You need a web server that can open multiple threads, so if you’re still using Webrick in development (which can’t receive or send anything while serving one browser), switch to Puma (simply add gem 'puma' to your Gemfile, and remove gem 'webrick' if it is mentioned somewhere).

ActionCable lives by default in a new directory below /app: channels.

You need to define the ActionCable::Channel and ActionCable::Connection classes as follows:

# app/channels/application_cable/channel.rb
module ApplicationCable
  class Channel < ActionCable::Channel::Base
  end
end

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
  end
end

Make sure you also properly configure config/cable.yml. Even for local development I would recommended using Redis (it’s a light install), since it allows you to trigger broadcasts from your Rails-console.

local: &local
  adapter: redis
  url: redis://localhost:6379

development: *local

test:
  adapter: async

production: *local

Make sure you’ve added the Redis gem to your Gemfile when you’re going to use this:

gem ‘redis’

On the client side we need to make sure there the ActionCable library is also present there:

# app/assets/javascripts/cable.coffee
#= require action_cable
#= require_self
#= require_true ./channels

@App ||= {}
App.cable = ActionCable.createConsumer()

Define a Channel for communication

Messages need to be broadcasted over a channel to which subscribers can listen to. Hence we first need to create a CheckList channel. We let our subscribers listen to the stream of changes on the CheckList model when they decide to actively start listening to a stream.

# app/channels/check_list_channel.rb
class CheckListChannel < ApplicationCable::Channel
  def subscribed
  end
  def start_listening stream_prefs
    check_list = CheckList.find stream_prefs['check_list_id']
    stream_for check_list
  end
  def stop_listening
    stop_all_streams
  end
end

Broadcasting changes

A broadcast starts with sending, in our case checking off a recipe.

Note: My start is always to make things work without (client side) Javascript first. I assume you have a CheckList model, with many CheckListItems and hence have a form looking something like this:

<%= form_for [@check_list] do |form| %>
  <%= form.simple_fields_for :check_list_items do |f| %>
    <label>
      <%=f.check_box :checked %>
      <%=f.object.name %>
    </label>
  <% end %>
  <%= f.submit %>
<% end %>

To start sending, simply add an after_save: broadcast_change action to the checklist item (you might want to move this to a separate Job, but lets keep it simple here):

# app/models/check_list_item.rb
def broadcast_change
  CheckListChannel.broadcast_to(recipe_collection, self)
end

If you’d save the form, old school style, it would trigger a broadcast of the CheckListItem on the CheckListChannel.

Hearing the broadcast

So we have a channel, which can serve our broadcast. But how can we hear the changes? We now turn to the JavaScript side of things (yep, this is a progressive enhancement), we let the earlier defined consumer subscribe to the RecipeCollectionChannel (now we’re just logging the response, we’ll do something in a sec):

# check_list_channel.coffee
App.checkListChannel = App.cable.subscriptions.create "CheckListChannel",
  received: (data) ->
    console.log(data)

Listening in

In the show.erb.html I add the following line, which tells the recipeChannel to tune into the right stream.

<script type="text/javascript">
setTimeout(function() {
  App.checkListChannel.perform("start_listening", {check_list_id: <%=@recipe_collection.id%>});
}, 1000)
</script>    

There is room for improvement here, but for now this works.

Doing something with it

The data returned is just a plain JavaScript object, with the values of the broadcasted model, in this case of a CheckListItem-instance. When we get an updated CheckListItem we also need to update the interface correspondently.

To find the checkbox associated with the CheckBoxItem we need to make sure we can easily find the checkbox in Javascript. Since Rails’ FormBuilder doesn’t generate a usable id by default, we will assign a predefined id to it.

<label>
  <%= f.check_box :checked, id:"check_list_item_#{f.object.id}_checked_field" %>
  <%= f.object.name%> 
</label>

We can now locate the checkbox that corresponds to the returned data by get the element by its id and update the status of the checkbox with the status of the model:

# check_list_channel.coffee
App.checkListChannel = App.cable.subscriptions.create "CheckListChannel",
  received: (data) ->
  input_id = 'check_list_item_' + data['id'] + '_checked_input'
  checked_status = data['checked']
  input = document.getElementById(input_id)
  input.checked = checked_status if input

If the input cannot be found, we could decide to render create new inputs on request now, although it might be nicer to alert the user that the list has changed and ask him or her to update the whole list.

Just updating the list when others make changes is fun, but it is even nicer if it doesn’t require hitting the submit button. So we register a new EventListener that gets triggered if it involves an item from our check list:

document.addEventListener('change', (e)->
  form = document.querySelector('form.edit_check_list[method=post]')
  form_input = form.querySelector('#'+e.target.id)
  if form_input
    message_id = parseInt(e.target.id.match(/check_list_item_(.*)_checked_input/)[1])
    message = {id: message_id, checked: e.target.checked}
    App.checkListChannel.perform("inform", message);
)

Yep, we’re calling an new method here on the CheckListChannel, so we must make sure it is understood at the Rails side of things:

We’re adding the following method to check_list_channel.rb:

def inform data
  rci = CheckListItem.find(data['id'])
  rci.checked = data['checked']
  rci.save
end

It will cause another message to be sent back to the client, which potentially could lead to firing another change-event, but since the truth about the state is always in the database potential conflicts will be resolved eventually :).

Authentication

That’s it for now. Right now we’re, however, allowing anyone to follow what is going on and modify everything when they tune in, you might want to limit that to certain users. That’s up for the next article.

The image by ccPixs was CC-BY licensed.

Op de hoogte blijven?

Maandelijks maak ik een selectie artikelen en zorg ik voor wat extra context bij de meer technische stukken. Schrijf je hieronder in:

Mailfrequentie = 1x per maand. Je privacy wordt serieus genomen: de mailinglijst bestaat alleen op onze servers.