Getting Real(time) with Rails

Realtime web UIs are all the rage and Rails isn't one to be left behind. This article is an investigation into how one might build a UI that renders continuosly updated values from the server without ever refreshing the page.

Software versions

I have used the following software and versions when writing this, YMMV:

Create the Rails application

Let's get started:

$ rails new meters --webpack --skip-active-record --skip-coffee
$ cd meters

This generates a blank Rails application called Meters in the meters directory.

We've asked Rails to set itself up using Webpack. We skip ActiveRecord so we won't have worry about databases for our simple app, and we skip Coffeescript because we don't need that either.

Use Webpack

While we've installed Webpack, the default layout still includes Javascripts from the asset pipeline, so let's change that. In app/views/layouts/application.html.erb change:

<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>

to

<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

Add something to look at

Let's keep it simple with a single controller with a single action/view:

$ rails generate controller Gauges show

then change app/views/gauges/show.html.erb to look like:

<div class="gauge">
  <div class="gauge-value"><%= rand(1..100) %></div>
</div>

Now, if you start your Rails application (by running rails server in your terminal) and visit http://0.0.0.0:3000/gauges/show you should see a value that changes randomly every time you refresh.

It doesn't look impressive, but if you feel like adding a bit of style you can add the following to app/assets/stylesheets/application.css:

body {
  font-family: system-ui;
}
.gauge {
  border: 1px solid #ccc;
  border-radius: 3px;
  box-shadow: rgba(0, 0, 0, 0.25) 0 0.25em 2em;
  margin: 1em;
  padding: 1em;
  text-align: center;
  width: 10em;
}
.gauge-value {
  font-size: 2.56em;
}

Add Stimulus

Following the Stimulus Handbook install guide:

$ yarn add stimulus

and replace console.log('Hello World from Webpacker') in app/javascript/packs/application.js with

import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"

const application = Application.start()
const context = require.context("./controllers", true, /\.js$/)
application.load(definitionsFromContext(context))

This sets up a basic Stimulus setup that we can start using. Let's add a Stimulus-controller for our gauges, by first creating a app/javascripts/packs/controllers then add app/javascripts/packs/controllers/gauge_controller.js inside it:

import { Controller } from "stimulus"

export default class extends Controller {
}

Now that we have a basic Stimulus controller that doesn't do anything, let's hook it up to our view. In app/views/gauges/show.html.erb change <div class="gauge"> to

<div class="gauge" data-controller="gauge">

This tells Stimulus that we want to use our gauge_controller.js for that part of the view and using Magic(tm) Stimulus connects the two for us.

Letting Stimulus update our view

Now that we've connected our view to Stimulus, we can expose bits and pieces of our view to Stimulus, allowing us to update it without knowing the structure of our markup.

We add a Stimulus target by changing <div class="value"> in app/views/gauges/show.html.erb to:

<div class="value" data-target="gauge.value">

and we need to tell the Stimulus controller about it as well by adding static targets = ["value"] inside our controller in stimulus_controller.js:

export default class extends Controller {
  static targets = ["value"]
}

This allows us to reference the DOM object with data-target="gauge.value as this.valueTarget in gauges_controller.js.

To demonstrate this, we'll add a connect() function in the controller, which Stimulus calls each time the controller is connected to our document:

export default class extends Controller {
  static targets = ["value"]

  connect() {
    const element = this.valueTarget
    element.innerHTML = Math.floor(Math.random() * Math.floor(100))
  }
}

You can read more about targets in the Stimulus Handbook.

Now, if you start your Rails application (by running rails server in your terminal) and visit http://0.0.0.0:3000/gauges/show you should see a value that changes randomly every time you refresh. Those numbers are generated by our Stimulus controller. Success!

Set up ActionCable backend

Now that our clientside Stimulus setup is operational, we turn our attention to the realtime part of our application; ActionCable.

Now we can create an ActionCable channel that we can communicate via. Create app/channels/gauges_channel.rb:

class GaugesChannel < ApplicationCable::Channel
  def subscribed
    stream_from "gauges"
  end

  def unsubscribed
    stop_all_streams
  end
end

This instructs ActionCable that everything broadcast to the "gauges" stream will be forwarded to all subscribed clients - and to stop streaming to clients when they unsubscribe.

And finally, in order to facility communication between different processes, we need a backend for ActionCable. By default it likes to use Redis, so let's install that.

Add to Gemfile and run bundle:

gem "redis"

and change the development section in config/cable.yml from

development:
  adapter: async

to

development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>

You're also going to need Redis server installed and running. Covering that installation is out of scope for this article, but it should be available from wherever you usually get your development software.

Set up ActionCable frontend

With the serverside part of ActionCable in place, we can start connecting clients to the channel from the clientside.

This first step here is going to seem a little dumb, but bear with me. We need to add the ActionCable javascripts to the project.

"But Jakob", I hear you exclaim, "didn't Rails add that by default when we generated our application?". Why yes, well spotted my imaginary reader, it did, but it assumed we wanted to use the Sprockets-based asset pipeline. We've opted for Webpack, so let's keep everything inside that:

$ yarn add actioncable

With that in place we can connect our Stimulus controller to our ActionCable channel. In app/javascripts/packs/application.js change the connect() function to look like:

connect() {
  var cable = ActionCable.createConsumer();
  cable.subscriptions.create('GaugesChannel', {
    received: function(data) {
    }
  });
}

and import ActionCable on the first line of the file:

import ActionCable from 'actioncable';

If you restart the Rails server and look at your Javascript console when refreshing the gauges/show page, you should see log messages in your Rails server output saying that a client has connected to your channel:

Successfully upgraded to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
GaugesChannel is transmitting the subscription confirmation
GaugesChannel is streaming from gauges

Closing the loop

We now have a way to send data from the serverside using GaugesChannel and we have a Stimulus controller connected to that channel ready to receive the data we send.

Let's do something with the data we receive. Change the received() function in app/javascripts/packs/controllers/gauges_controller.js to

received: (data) => {
  console.log('received', data);
  const element = this.valueTarget;
  element.innerHTML = data;
}

Note that we're using the arrow function syntax to ensure that this still references our Stimulus controller.

With that in place, your Redis server running and your Rails server running, refresh the gauges/show page in your browser and fire up a rails console:

$ rails console
> ActionCable.server.broadcast('gauges', 42)

If all goes well, your web UI should update automagically whenever your broadcast to 'gauges' from your console. Give it a few shots:

ActionCable.server.broadcast('gauges', 42)
ActionCable.server.broadcast('gauges', rand(1..100))
ActionCable.server.broadcast('gauges', "Hi!")

😃