Real-time Rails with web sockets and React
Tags: rails, websocket, actioncable, tutorial
Published: 2021-07-29 16:12:20

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](https://pg-redis-cable-sdq.herokuapp.com/). 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](https://guides.rubyonrails.org/action_cable_overview.html#running-standalone-cable-servers) and [npm @rails/actioncable](https://www.npmjs.com/package/@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:
```rb
#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:28080/cable"
...
#app/views/layouts/application.html.erb
<%= action_cable_meta_tag %>
```
or
```js
// /app/javacsript/channels/consumer.js
createConsumer('ws://${window.location.hostname}:28080/cable')
```
In production, you may need to adjust with your (sub)domain and `wss`.

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

```rb
#/config/environnemnts/development.rb
config.action_cable.url = "ws://localhost:3000/cable"
```
It will be parsed by the client Javascript to get the URL.

#### For the backend (local config)

- subscribe the Redis adapter ("cable.yml")
```yml
development:
  adapter: redis
  url: <%= ENV["REDIS_URL"] %>
  channel_prefix: cable_prod
```

- 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.

```rb
#/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
```

Add:
```rb
#/cable/config.ru
require_relative "../config/environment"
Rails.application.eager_load!
run ActionCable.server
```

Now you can launch the standalone cable process with:
 ```rb
bundle exec puma -p 28080 cable/config.ru
```



## 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:

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

/javascript/channels
    |_ consumer.js
    |_ index.js
    |_ counter_channel.js
    |_ hits_channel.js
```

In our routes:

```rb
#app/config/routes.rb
mount ActionCable.server => '/cable'
```

### The "click counter" channel

```js
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
```

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.

:heavy_exclamation_mark: 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`):

```rb
#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
```

and client-side, we have:

```js
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;
```
The "data" is an object sent from the component "Button".


### The "hit page counter" channel

```js
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
```

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:

```rb
#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
```

and client-side:

```js
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;
```

### The "button"

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

```js
...
import Button from "./components/Button.jsx";
document.addEventListener("DOMContentLoaded", () => {
  ReactDOM.render(
    <Button />, document.getElementById("root")
  );
});
```

```js
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;
```

```js
#fetchCounters.js
export default async (path) => {
  return fetch(path, { cache: "no-store" }).then((res) => res.json());
};
```
```js
#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());
};
```

## Run this

On localhost. You can use for example  [overmind](https://github.com/DarthSim/overmind) to run the **Procfile**. Firstly start Nginx and Postgres, then  ` overmind start`

```sh
#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
```

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:

```conf
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;
   }
}
```

Happy coding!