DEV Community

A simple guide to ActionCable

Hello folks. First blog post here on dev.to. For whom doesn't know me already (likely most of you), my name is Andrea and I am a freelance full-stack developer. On top of that, I often teach Ruby, Rails and Javascript at a coding bootcamp called LeWagon.

It's been already two bootcamps in a row that some students had a really tough time integrating ActionCable in their app, mostly because the official Rails docs are pretty vague and there are a lot of contradicting blog posts on the internet. Well, fear no more. With this guide I'll try to make it as easy and smooth as possible. Enjoy the read.

Quick app introduction

Much like my students, we'll create a (very) bare-bones Uber Chat application where we're going to have an Order that will connect a User and a Driver. These two, should then be able to exchange messages on the Order show page.

Rails app setup

Let's start by creating a new Rails project by running rails new uber_chat in your terminal

This will create a uber_chat directory with a shiny new Rails 6 project.

Let's now add user authentication using good ol' Devise.

This line goes into our Gemfile

# Gemfile
gem 'devise'

Let's now run bundle install to make the devise Gem available in our project. Now, we need to let devise add some boilerplate code in our project by running rails g devise:install first and rails g devise User after.

This last command will add the devise routes to our routes file and will generate a migration and model files for our users. Let's now add the Users table to our db by running rails db:migrate.

Adding drivers and orders

Let's now add two new models to our DB

rails g model driver user:references
rails g model order user:references driver:references

This will create the Driver and Order models together with the respective migrations. Let's now migrate the db with rails db:migrate and add the following to the User model.

# User.rb
has_one :driver
has_many :orders

Sweet! Now our users can have many orders and some of them, can also be drivers. Let's add some seed data so that we can actually use the application. Paste this in your seed file

# seeds.rb
puts "Resetting the db..."

User.destroy_all
Driver.destroy_all
Order.destroy_all

puts "Creating a user..."
user = User.create email: 'user@test.com', password: 'password', password_confirmation: 'password'

puts "Creating a driver"
driver_user = User.create email: 'driver@test.com', password: 'password', password_confirmation: 'password'
driver = Driver.create user: driver_user

puts "Creating an order..."
Order.create user: user, driver: driver

And now run rails db:seed

Adding the messages and the views

Let's add now a new model (and table) to the app to actually store the messages

rails g model message content:string user:references order:references
rails db:migrate

and let's add the relationship also on the User and Order models.

# User.rb
has_many :messages
# Order.rb
has_many :messages

Disclaimer #1 - here we could have gone deeper and we could have set up a more appropriate relationship to store the message author, however, because that is not the core part of this article I want to make it easier and quicker

Now we will be able to create messages written by Users scoped to a certain order. Good stuff.

Let's now add the Order index route that we'll use as our root path and the Order show route that will actually be responsible of showing the messages.

In the routes file, let's add this

# routes.rb
root to "orders#index"
resources :orders, only: %i[index show]

We can now generate the Order controller and views using rails g controller orders show index and let's make the controller look like so

class OrdersController < ApplicationController
  before_action :authenticate_user!
  def index
    @orders = current_user.orders
  end

  def show
    @order = Order.find params[:id]
  end
end

Nothing extraordinary here. We first make sure that the two pages are authentication protected (otherwise current_user.orders would give us an error) and then we grab all the orders belonging to a user in the index and the order matching the :id parameter in the show.

Now let's get the views done


<!-- orders/index.html.erb -->

<h1> Yo! These are your orders </h1>

<ul>
  <% @orders.each do |order| %>
    <li> <%= link_to "Order #{order.id}", order_path(order) %> </li>
  <% end %>
</ul> 
<!-- orders/show.html.erb -->
<h1> Order #<%= @order.id%> </h1>

<h3> Messages </h3>

<div
  class="messages-box"
  data-order-id="<%= @order.id %>"
></div>

<form class="new-message-form">
  <input type="text" placeholder="Hola!" class="new-message-input">
  <input type="submit">
</form>

The index file is pretty simple. We're just displaying all the orders belonging to a user.

The show file is slightly more interesting. Here we are storing the order id as data attribute of the .messages-box div. This will come in handy in a little bit. We're also adding the form that we'll later user in order to create new messages.

Adding the API endpoint and Javascript calls

In order to fetch the messages after page load and post a new message once a user has typed it, I like to add a /api namespace to my app in order to keep things nice and tidy.

Let's add this stuff to the routes file

# routes.rb
namespace :api, defaults: { format: :json } do
  namespace :v1 do
    resources :orders do
      resources :messages, only: %i[index create]
    end
  end
end

The code above will now make it very easy to GET and POST to /api/v1/orders/:order_id/messages.

Let's now create the appropriate folders and controller with the command mkdir -p app/controllers/api/v1 && touch app/controllers/api/v1/messages_controller.rb

And add this code to the newly created MessagesController

# messages_controller.rb
module Api
  module V1
    class MessagesController < ApplicationController
      skip_before_action :verify_authenticity_token

      def index
        order = Order.find params[:order_id]
        messages = order.messages

        render json: messages
      end

      def create
        order = Order.find params[:order_id]
        message = Message.new message_params

        message.user = current_user
        message.order = order

        render json: message if message.save
      end

      private

      def message_params
        params.require(:message).permit(:content)
      end
    end    
  end
end

Disclaimer #2 - This is not an API post hence you shouldn't use this as a guide to create a robust API. In this case, for example, I'm not securing the endpoints for simplicity sake.

Now that the endpoints are ready, we can add our JavaScript code to load the messages on page load and to create new messages.

Lets run mkdir app/javascript/plugins -p && touch app/javascript/plugins/messagesPlugin.js

In this file, let's now add this stuff

//messagesPlugin.js

const fetchMessages = async () => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    const orderId = messagesBox.dataset.orderId

    const res = await fetch(`/api/v1/orders/${orderId}/messages`)
    const messages = await res.json()

    messages.forEach(message => addMessageToDom(message))
  }
}

const createMessage = () => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    const form = document.querySelector('.new-message-form')
    const input = document.querySelector('.new-message-input')
    const orderId = messagesBox.dataset.orderId

    form.addEventListener('submit', async (e) => {
      e.preventDefault()

      const res = await fetch(`/api/v1/orders/${orderId}/messages`, {
        method: 'post',
        headers: { 'Content-type': 'application/json' },
        body: JSON.stringify({
          message: {
            content: input.value
          }
        })
      })

      const message = await res.json()

      addMessageToDom(message)
    })
  }
}

const addMessageToDom = (message) => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    messagesBox.insertAdjacentHTML('beforeend', `<p> ${message.content} by <strong> User #${message.user_id}</strong></p>`)
  }
}

export { fetchMessages, createMessage, addMessageToDom }

Ok, there's a little more stuff to talk about here. The first fetchMessages function is simply needed to make a GET request to our API in order to show all the messages on page load.

Then, the createMessage function is needed in order to POST a new message to our API.

Finally, the AddMessageToDom function is simply needed to show the messages on the page.

Now, because we're exporting fetchMessages and createMessage, we also need to import them in our entry file packs/application.js. Let's paste this code in there

// application.js

import { fetchMessages, createMessage } from '../plugins/messagesPlugin'


document.addEventListener("turbolinks:load", function() {
  fetchMessages()
  createMessage()
})

Awesome! Now we're able to create messages directly from the application and the new messages will be displayed immediately to the page. Go have a look for yourself.

Start the server using rails s, then login using the credentials used to create the user in the seed file (user@test.com and password) and then go to the Order #1 page and try to create a message.

Just because we can see the message, that doesn't mean that the other person (the driver, for instance) will be able to do so. This is because we need to establish a communication channel between our backend and our frontend using ActionCable.

Adding ActionCable

The first thing we want to do when dealing with ActionCable is generating a new channel. This can be done using the command rails g channel chat.

This will generate a bunch of files for both the Ruby and the Javascript parts. Let's start adding some code to make it work.

In chat_channel.rb let's add this code

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "order_#{params[:order_id]}"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

Here, thanks to the interpolation of the order_id we'll "stream" a different "chat room" for each different order.

in chat_channel.js let's add this

import consumer from "./consumer"
import { addMessageToDom } from '../plugins/messagesPlugin'

const initChatChannel = () => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    const order = messagesBox.dataset.orderId

    consumer.subscriptions.create({
      channel: "ChatChannel",
      order_id: order
    }, {  
      connected() {
        console.log('Connected...')
      },
      received({ message }) {
        console.log('Receiving stuff...')
        addMessageToDom(message)
      }
    })
  }
}

export { initChatChannel }

Here, we're using consumer.subscriptions.create in order to create a new client that will actively listen for new messagess added to the "chat room".

You can see that the first argument of the create method is an object that will be used to establish the connection to a specific channel (ChatChannel in this case). It's also import to pass the order_id parameter in order to establish the connection to the right chat room as well (remember the params[:order_id] used in chat_channel.rb?).

The second argument, instead, is again an object that contains two methods. The connected method will simply run whenever the client gets connected to the ActionCable server. The received method, instead, is what makes the whole magic work. This method is automatically triggered whenever a new message is broadcasted on the channel that we connected our client to.

In this case, we want to grab that message and pass it as an argument to the addMessageToDom method that we're importing from the plugin we created earlier so that it can get displayed to the DOM.

Finally, we need to tell the server to broadcast a message every time a new message is created on the database. To do so, we can edit our Message model to look like so

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :order

  after_create :broadcast_through_action_cable

  private

  def broadcast_through_action_cable
    ActionCable.server.broadcast("order_#{self.order.id}", message: self)
  end
end

Here, the broadcast_through_action_cable will simply broadcast the message to the right room according to the order id the message belongs to.

Let's now get rid of some (now) useless code. Go to the messagesPlugins.js and replace whatever you got in there with this

const fetchMessages = async () => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    const orderId = messagesBox.dataset.orderId

    const res = await fetch(`/api/v1/orders/${orderId}/messages`)
    const messages = await res.json()

    messages.forEach(message => addMessageToDom(message))
  }
}

const createMessage = () => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    const form = document.querySelector('.new-message-form')
    const input = document.querySelector('.new-message-input')
    const orderId = messagesBox.dataset.orderId

    form.addEventListener('submit', (e) => {
      e.preventDefault()

      fetch(`/api/v1/orders/${orderId}/messages`, {
        method: 'post',
        headers: { 'Content-type': 'application/json' },
        body: JSON.stringify({
          message: {
            content: input.value
          }
        })
      })
    })
  }
}

const addMessageToDom = (message) => {
  const messagesBox = document.querySelector('.messages-box')
  if (messagesBox) {
    messagesBox.insertAdjacentHTML('beforeend', `<p> ${message.content} by <strong> User #${message.user_id}</strong></p>`)
  }
}

export { fetchMessages, createMessage, addMessageToDom }

Here we simply removed the call to the addMessageToDom method from the createMessage method because now we're outsourcing the creation of new messages to the ActionCable receiver.

This is the result of all of this

Action cable in action

Hope you guys found this helpful. (Contructive) Criticism is always very welcome. If you got any question, feel free to comment down below or tweet me @ilrock__. To check out the full source-code this is the GitHub link.

Top comments (0)