DEV Community

NDREAN
NDREAN

Posted on • Updated on

Real-time Rails with web sockets and React

Yet Another Tutorial. I focus on going quickly to the relevant paths to achieve running a rails SSR app with a standalone ActionCable process, with (P)React rendering with two use case of web sockets. The authentication part and reliability discussions are eluded here.

The ActionCable process can be embedded in the Rails process or packaged as a standalone process. Both configs are shown below. Since you may use background jobs to delegate the broadcast, this would need another process too.

Why (P)React? Because any broadcasted data via a web socket can be used to mutate a state, and therefor trigger a rendering, just with a useEffect, no soup. If you use Preact, you save 30k and have a lightweight easy going JS frontend; just alias react and react-dom to preact/compat in Webpack (and yarn add preact preact-compat), nothing else :)

Instead of a traditional chat app, I want to build an app that manages realtime inventories and broadcasts the total page hits. It has a button that on-click increments a counter and broadcasts it; this can easily simulate the action of a customer filling his basket and monitoring the stock. We also increment a total page hit counter server-side and broadcast it: this simulates the total connected customers. The code below can be easily adapted to achieve this.

A demo app is visible on Heroku. Open several browsers to check.

Config

We will setup the backend and the frontend for a standalone cable process. The frontend requires the installation of the npm package @rails/actioncable, and the backend to enable the middleware action_cable/engine. Rails guide standalone and npm @rails/actioncable

For the frontend

  • run npm i @rails/actioncable
  • tell to the client where to connect for the standalone mode, so target the cable process:
#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:28080/cable"
...
#app/views/layouts/application.html.erb
<%= action_cable_meta_tag %>
Enter fullscreen mode Exit fullscreen mode

or

// /app/javacsript/channels/consumer.js
createConsumer('ws://${window.location.hostname}:28080/cable')
Enter fullscreen mode Exit fullscreen mode

In production, you may need to adjust with your (sub)domain and wss.

🔵 If you wish to run ActionCable packaged with Rails, target the current port of the Rails process:

#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:3000/cable"
Enter fullscreen mode Exit fullscreen mode

It will be parsed by the client Javascript to get the URL.

For the backend (local config)

  • subscribe the Redis adapter ("cable.yml")
development:
  adapter: redis
  url: <%= ENV["REDIS_URL"] %>
  channel_prefix: cable_prod
Enter fullscreen mode Exit fullscreen mode
  • enable the middleware, require "action_cable/engine" in "/config/application.rb"
  • you may want to restrict the origin. In development, it's localhost and in production you (sub)domain.
#/config/application.rb
require "action_cable/engine"
[...]

#config/environments/dev.rb
Rails.application.configure do
  [...]
  origins = ['http://localhost:3000','http://localhost:9000']
  config.action_cable.allowed_request_origins = origins
end
Enter fullscreen mode Exit fullscreen mode

Add:

#/cable/config.ru
require_relative "../config/environment"
Rails.application.eager_load!
run ActionCable.server
Enter fullscreen mode Exit fullscreen mode

Now you can launch the standalone cable process with:

bundle exec puma -p 28080 cable/config.ru
Enter fullscreen mode Exit fullscreen mode

Example app

The app is a standard SSR app with React embedded (ie installed via rails webpacker:install:react). We count and broadcast the number of page loads and the number of clicks on a button. It has a unique component, named "Button".

We run rails g channel counter and rails g channel hits. This will generate the following pre-filled files:

/app/channels
  |_ /application_cable
  |_ counter_channel.rb
  |_ hits_channel.rb

/javascript/channels
    |_ consumer.js
    |_ index.js
    |_ counter_channel.js
    |_ hits_channel.js
Enter fullscreen mode Exit fullscreen mode

In our routes:

#app/config/routes.rb
mount ActionCable.server => '/cable'
Enter fullscreen mode Exit fullscreen mode

The "click counter" channel

counter_channel.js            <=> counter_channel .rb
stream_from "counter_channel" <=> { channel: "CounterChannel" }

handleClick => increment counter => 
counterChannel.sending(data) =>
sending(){ this.perform ('receiving', data) } => 
receiving.rb => ActionCable.server.broadcast(data) => 
counterChannel.received(data) => mutate state
=> render
Enter fullscreen mode Exit fullscreen mode

The handleClick() of "Button" calls a custom function counterChannel.sending() and pass data to it. This function is defined in the web socket wrapper "counter_channel.js". The custom function sending() calls with this.perform() a server-side custom callback method CounterChanel::receiving.rb. Any transferred data must be in the form "{key:value}". The server saves to the database and broadcasts back the data to any connected client.
The web socket wrapper has a built-in listener received() that we overwrite in the "Button.js" component within a useEffect hook that will render. It will mutate the state if any data is present. If there is no data, for example on page refresh, then we fetch from the database and mutate the state accordingly. This is needed on page refresh, since the connection is lost and renewed. A simple switch is implemented to realise this.

You basically transformed a POST request to Postgres into a web socket call that saves to the database.

❗ However, this relies on the continuity of the communication; if it is important to save the client state, then the traditional POST request of the value of the counter to an SQL database and broadcast back through the socket is safer in this case. This means instead of calling counterChannel.sending() from the component, you would perform a POST from it and create an endpoint with a controller and put the code below into it.

Server-side, we have (we have a model Counter):

#app/controllers/counter_channel.rb
class CounterChannel < ApplicationCable::Channel
  def subscribed
    stream_from "counter_channel"  # pubsub
  end

  def receiving(msg)
    # the value of the counter is incremented client-side
    counter = Counter.create!(nb: msg['countPG'] )
    data = {}
    data['countPG'] = msg['countPG']
    puts data
    ActionCable.server.broadcast('counter_channel', data.as_json) if counter.valid?
  end

  def unsubscribed
    stop_all_streams
  end
end
Enter fullscreen mode Exit fullscreen mode

and client-side, we have:

import consumer from "./consumer";
const counterChannel = consumer.subscriptions.create(
  { channel: "CounterChannel" },
  {
    connected() { },
    disconnected() {},
    received: function (data) {
      // overwritten in "Button.jsx" called when there's incoming data on the web socket for this channel
    },
    sending(data) {
      this.perform("receiving", data);
    },
  }
);
export default counterChannel;
Enter fullscreen mode Exit fullscreen mode

The "data" is an object sent from the component "Button".

The "hit page counter" channel

hit_channel.js               <=> hit_channel .rb
stream_from "hit_channel"    <=> { channel: "HitChannel" }
send()                       <=> receive

connected() {this.perform('trigger_hits')} => 
trigger_hits.rb => ActionCable.server.broadcast
=> hitChannel.received => mutate state
=> render
Enter fullscreen mode Exit fullscreen mode

On page load, the client connects to the web socket defined.

A channel has a predefined connected() function. The predefined callback method is HitChannel::subscribed.rb. In particular, stream_for "hit_channel"

In our case, we decided as an exercise to implement a custom method server-side. We call with this.perform() a custom server-side callback method called HitChannel::trigger_hits.rb. It increments the database (a Redis store) and broadcasts the data back to any connected client. Then, client-side, the web socket wrapper has a listener received() that we overwrite in the "Button.js" component". It will update the state within a useEffect.

If we want safety, we would directly retrieve, increment and broadcast the counter from the home page controller.

We could also have use the built-in send. For example, connected(){ this.send({msg: "new connection") }. It's server-side alter ego callback is the predefined method receive. The actions would have been put in this method.

Server-side, we have:

#app/channels/hit_channel.rb
class HitChannel < ApplicationCable::Channel
  def subscribed
    stream_from "hit_channel"
    # the code in "trigger_hits" could be here without using this custom method
  end

  def trigger_hits
    REDIS.incr('hits_count')
    data = {}
    data['hits_count'] = REDIS.get('hits_count').to_i
    puts data
    ActionCable.server.broadcast('hit_channel', data.as_json)
  end

  def receive(data)
    # responds to `hitChannel.send()`
    puts data
  end

  def unsubscribed
    stop_all_streams
  end
end
Enter fullscreen mode Exit fullscreen mode

and client-side:

import consumer from "./consumer";
const hitChannel = consumer.subscriptions.create(
  { channel: "HitChannel" },
  {
    connected() { 
      //<- predefined callback HitChannel::subscribed.rb
      console.log("Client connected");

      //<- custom callback "HitChannel::trigger_hits.rb" called
      this.perform("trigger_hits"); 

      //<- predefined callback HitChannel::receive.rb"
      this.send({ msg: "new connection" }); 
    },
    disconnected() {},
    received: function (data) {
      // overwritten in Button.jsx
    },
  }
);
export default hitChannel;
Enter fullscreen mode Exit fullscreen mode

The "button"

The 'Button.jsx" component imported in "/packs/application.js"

...
import Button from "./components/Button.jsx";
document.addEventListener("DOMContentLoaded", () => {
  ReactDOM.render(
    <Button />, document.getElementById("root")
  );
});
Enter fullscreen mode Exit fullscreen mode
import React, { useState, useEffect } from "react";
import fetchCounters from "../utils/fetchCounters.js";
import counterChannel from "../../channels/counter_channel.js";
import hitChannel from "../../channels/hit_channel.js";

const Button = () => {
  const [counters, setCounters] = useState({});
  const [hitCounts, setHitCounts] = useState();

  useEffect(() => {
    async function initCounter() {
      try {
        hitChannel.received = (data) => {
          if (data) return setHitCounts(data.hits_count);
        };

        counterChannel.received = (data) => {
          if (data.countPG) {
            return setCounters({ countPG: data.countPG });
          }
        };

        const { countPG } = await fetchCounters("/getCounters");
        setCounters({ countPG: Number(countPG) });
      } catch (err) {
        console.warn(err);
        throw new Error(err);
      }
    }
    initCounter();
  }, []);

  const handleClick = async (e) => {
    e.preventDefault();
    try {
      let { countPG } = counters;
      (!countPG) ? countPG=0 : countPG += 1;
      counterChannel.sending({countPG});
    } catch (err) {
      console.log(err);
      throw new Error(err);
    }
  };

  return (
    <>
      <div className="flexed">
        <button className="button" onClick={handleClick}>
          Click me!
        </button>

        {counters && (
          <div>
            <h1>Click counter: {counters.countPG}</h1>
            <br />
            <h1>Total page hits: {hitCounts}</h1>
          </div>
        )}
      </div>
    </>
  );
};
export default Button;
Enter fullscreen mode Exit fullscreen mode
#fetchCounters.js
export default async (path) => {
  return fetch(path, { cache: "no-store" }).then((res) => res.json());
};
Enter fullscreen mode Exit fullscreen mode
#postCounters.js
import { csrfToken } from "@rails/ujs";

export default async (path, object) => {
  return fetch(path, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "X-CSRF-Token": csrfToken(),
    },
    body: JSON.stringify(object),
  }).then((res) => res.json());
};
Enter fullscreen mode Exit fullscreen mode

Run this

On localhost. You can use for example overmind to run the Procfile. Firstly start Nginx and Postgres, then overmind start

#Procfile
assets:  ./bin/webpack-dev-server
web:     bundle exec rails server
redis-server:   redis-server redis/redis.conf
worker:  bundle exec sidekiq -C config/sidekiq.yml
cable: bundle exec puma -p 28080 cable/config.ru
Enter fullscreen mode Exit fullscreen mode

There you will see that the Rails process does not log anything about "ActionCable", only the cable process replies.

On Docker, you use the Rails image to run two containers, one with Rails and one for a cable process, launched with the above command.
If the Rails service is called app and your cable process is called cable and you would like to reverse-proxy it with Nginx, then the config file to pass along the web sockets would be like:

upstream puma {
  server app:3000;
}
server {
   listen 9000;
   root /usr/share/nginx/html;
   try_files  $uri  @puma;
   access_log  off;
   gzip_static on;
   sendfile    on;
   expires     max;
   add_header  Cache-Control public;
   add_header  Last-Modified "";
   add_header  Etag "";

   location @puma {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_pass_header   Set-Cookie;
      proxy_redirect off;
      proxy_pass http://puma;
   }
   location /cable {
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_pass http://cable:28080;
   }
   error_page 500 502 503 504 /500.html;
   location = /500.html {
      root /usr/share/nginx/html;
   }
}
Enter fullscreen mode Exit fullscreen mode

Happy coding!

Discussion (0)