A brief introduction to Action Cable

23 Sep 2019 - John


As websites started to get more complex and the need for user interactivity grew in demand, one of the early problems of the web was its unidirectional nature. Users were able to retrieve information, but sending, and let alone interact with each other in real time, was a cery difficult challenge.


credit: sulmanweb

The early web implemented a few solutions in the form of java applets, complete programs that run inside a web page, but that were also slow and resource intensive. Then came polling through javascript, which requested the server for any changes every few seconds and refreshed accordingly, but the impracticalities were the same. The industry used a few solutions like DHTML, Microsoft's XMLHTTP -which was the basis for XMLHttpRequest- and ajax to a varying degrees of success until websockets arrived.

What are websockets?

Websockets is a protocol that sits on top of TCP/IP to allow a single full duplex connection. What this means is that, unlike regulat HTTP, where the client requests, the server delivers and shuts down the connection, with websockets we have a connection that stays open waiting for requests or responses from either side for as long as we want, without having to do expensive page reloads or resort to weird tricks to present the data.

What can we do with websockets?

Pretty much anything that involves changing anything on a page that's already been rendered. Financial tickers, message notifications, online chats, sports results, you name it!

Cool, how can I use it in Rails?

Luckily, everything we need to use Websockets in Rails is readily available and it's super easy to set up. First we have a data store, in our case Redis, that will act as a server for sending and receiving messages. Then we have a series of channels that our users subscribe to. These subscriptions are the actual websockets. It is important to note that we won't be interacting directly with Redis for anything else than the initial setup, since Rails will take care of everything under the hood. Let's get started!

Initial setup

First we need to install Redis. You can install it on the Mac through Homebrew by issuing brew install redis. If you're on Arch Linux, you can do sudo pacman -Syu redis and if you're in Ubuntu, you can run sudo apt install redis-server. Windows users can try their luck with a very outdated port or Microsoft's own spin, which is also outdated, so I would suggest something like running WSL and installing for Ubuntu. After installing Redis, you can leave everything at their default values, but if you want to take a look or change things around, check in /etc/redis.conf.

Next we will create a new Rails app, fire up our code editor and check ./config/cable.yml and change the development values for adapter and url:

development:
  adapter: redis
    url: redis://localhost:6379/1

Then we need to generate a channel, so type rails g channel general. This will create a few files, but the ones we're interested in are:

  • ./app/channels/general_channel.rb: This file tells Rails what to do when the user subscribes and unsubscribe to a channel.
  • ./app/javascript/channels/general_channel.js: This is where we're going to define the frontend logic of our channel. If you want to test your setup, you can console.log a message in the connected() method. Note that it won't work until the next step, but it should show up right away when you open a page.

To finish the setup, we go to ./app/channels/general_channel.rband change the subscribed method to include stream_from "general_channel". With this, our chat is setup and now it's just a matter of connecting everything together.

First our routes, we only need three of them:

  get '/', to: 'chat#index'
  post '/messages', to: 'chat#create'
  get '/messages/new', to: 'chat#new'

Next we need a way to persist our messages, so type rails g model Message content:text We also need a controller for our actions so type rails g controller chat and define the following methods inside:

def index
        @messages = Message.all
        @message = Message.new
    end

    def show
        @messages = Message.all
    end

    def create
        @message = Message.create(msg_params)
        if @message.save
            ActionCable.server.broadcast "general_channel", content: @message.content
        end
    end

    def new
        @message = Message.new
    end

    private  def msg_params
        params.require(:message).permit(:content)
    end

Nothing new under the sun here except for our create method. The first part creates a new message and if it gets saved, it triggers the second part, which is the broadcast to the general channel with the message body as it contents.

Now ne need a view to make use of our new superpowers, so go to ./views/chat and create a new index.html.erb. Inside, we're just going to build a simple form:

<h1>Chat</h1>

<div id="chat-container">
<ul id="message-list">
<% @messages.each do |message| %>
    <li><%= message.content %></li>
<% end %>
</ul>
</div>
<hr>
<%= form_with model: @message, html: {class: "message-form"} do |f| %>
<%= f.text_field :content, class:'message-input' %>
<%= f.submit "Send message!", class: "send-button" %>
<% end %>

If you send a message, nothing happens. I know you're excited, but whatever you sent was saved to the database! If you refresh the page, you can see your message, so all we need to do now is update our dom in ./app/javascript/general_channel.js:

consumer.subscriptions.create("GeneralChannel", {
  connected() {
    // Called when the subscription is ready for use on the server
    console.log("Connected to General channel");
  },

  disconnected() {
    // Called when the subscription has been terminated by the server
  },

  received(data) {
    // Called when there's incoming data on the websocket for this channel
    console.log(data);
    addMessage(data.content);
  }
});

function addMessage(message) {
  const messageList = document.getElementById("message-list");
  const containerDiv = document.getElementById("chat-container");
  let newComment = document.createElement("li");
  const messageInput = document.getElementsByClassName("message-input")[0];
  newComment.innerText = message;
  messageList.appendChild(newComment);
  messageInput.value = '';
  containerDiv.scrollTop = containerDiv.scrollHeight;
}

We are defining a function called addMessage() that takes a message as a parameter and then update the dom with the content of our message. Now we just need a few styles:

body {
    box-sizing: border-box;
}

.chat-container {
    width: 700px;
    margin: 0 auto;
    height: 500px;
    border: 1px solid #ddd;
}

h1 {
    text-align: center;
}

hr {
    border-top: 1px solid #ddd;
    border-bottom: none;
    width: 700px;
    margin: 0 30px 0 30px  auto;
}

#chat-container {
    width: 700px;
    margin: 0 auto;
    height: 500px;
    overflow-y: auto;
    border: 1px solid #ddd;
}

.message-form {
    display: flex;
    flex-flow: row wrap;
    align-items: center;
    width: 700px;
    margin: 0 auto;

}

.message-input {
    vertical-align: middle;
    height: 28px;
    width: 530px;
}

.send-button {
    border: none;
    background-color: #0086c3;
    color: #fff;
    padding: 8px 16px;
    margin-left: 16px;
    // width: 80px;
}

.send-button:hover {
    background-color: #29b6f6;
    cursor: pointer;
}

And that's it! Now go make some awesome chat!