Integrating Action Cable with React, the “It Works” Way

tl;dr: Read it or implement polling.

You can do this.

I come to you this week with a message: integrating Rails’s Action Cable with React is possible. I’ve done it (with credit to my talented partners, David Tomczyk and Joe Teichman), and you can too. Here’s how.

What is Action Cable, and do I need it?

Action Cable allows you to not only pass data to your back end, as you normally would, but also to broadcast that new data to any other users subscribed to your channel (and room) and have that new data render changes on the other subscribers’ DOMs, without triggering a page refresh. The classic example is a chat app:

  1. A user (let’s call him Bob) joins a chatroom with his friend, uh, Jim. By joining the chatroom — loading the webpage for that room — both Bob and Jim have each subscribed to the appropriate Action Cable channel for that chatroom. This means that Bob and Jim each have an open connection — through the server — to each others’ machines.
  2. Bob types out a message to his friend — uh, Jim. For example, “Hi, Jim!”
  3. When Bob sends that message, the message probably changes the state of some Component in Bob’s DOM, which triggers a re-render, allowing Bob to see “Hi, Jim!” on his screen immediately.
  4. The message is also, of course, sent to the server, so that Jim can see the message when he refreshes his page, and so that the server can enter the message in its database (if it keeps track of users’ messages).
  5. So where does Action Cable come in? Well, when Bob sends that message, we can use Action Cable to broadcast that message over the open connection — again, through the server — between Bob and Jim’s machines. So when Bob sends that message, we send it to the server (as per usual), but we also broadcast the message over the open Action Cable so that we can render it on Jim’s screen without a page refresh.

The big takeaway here is that setting up Action Cable between a Rails back end and a React front end will require changes in both ends — on the back end, we will need to set up our server so it knows 1) when to open a channel/room, 2) when to broadcast new data to subscribed users, and 3) when to close a channel/room. On the front end, we will need to set up Components that can receive incoming data broadcast over the Action Cable, as well as callback functions to handle that incoming data. So before you continue, ponder deeply on this: do I need Action Cable?

What are you talking about?

In a word (or several), I am talking about pushing updated data from the back end client-side and rendering it in real-time, without a specific request for new data from the client. When Bob sends “Hi, Jim!” to Jim, Jim does not want to have to refresh the page to see Bob’s message. Couldn’t that problem be solved with polling? Sure. But Action Cable is faster, since the new data — “Hi, Jim!” — is broadcast to Jim as soon as it hits the server. It is pushed by the server, rather than pulled (as in polling or a page refresh) by Jim.

How do I set up Action Cable in my Rails back end to work with my React front end?

Great question. Imagine you are building the future of line (queue) management apps — QSmart. You want users to be able to sign up to join a line, and you want creators of lines (e.g., business owners) to be able to display the line, in its present state, on a screen. Clearly, your database will need to be able to store information on many different lines as they are created and as people join them. But you also want the display page for a given line to update immediately (re-render) when 1) someone joins the line, 2) someone leaves the line, or 3) the first person in line is served, which moves everybody up one place in line.

On line online at QSmart

Here are some links to the QSmart repos on GitHub in case you’d like to dive deeper:

Front end — https://github.com/davidtom/qsmart-react

Back end — https://github.com/davidtom/qsmart-api

I might be bouncing between the front and back ends as we go along, but let’s start in the back end. First, you’ll need a channel for your lines.

## BACK
rails generate channel Line

Check out the foldersapp/channels and app/channels/application_cable, the latter of which will contain channel.rb and connection.rb. No need to touch these; they just lay the groundwork (really wanted a cable pun there… lay the cables?) for your channel. It also adds app/channels/line_channel.rb, which we should look at:

## BACK app/channels/line_channel.rb as generated by rails g channel Line
class LineChannel < ApplicationCable::Channel
def subscribed
# stream_from "some_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

That’s a start. See those two methods there, subscribed and unsubscribed? What’s up with those?

subscribed is what happens when someone loads the relevant line page — by viewing the line, they have subscribed to that channel. unsubscribed is called when they leave the channel. We’ll need to edit these, and add one more method:

## BACK app/channels/line_channel.rb
class LineChannel < ApplicationCable::Channel
def subscribed
@line = Line.find_by(id: params[:room])
stream_for @line
end
  def received(data)
LineChannel.broadcast_to(@line, {line: @line, users:
@line.waiting_users})
end
  def unsubscribed
end
end

If subscribed and unsubscribed are called when a client starts and stops viewing a particular line show page, respectively, then what is received(data)? You guessed it — it’s called when our channel receives new data. Let’s talk about subscribed first, though: when a user begins viewing a line show page, we declare that @line = Line.find_by(id: params[:room]). We find a line in our database with id that matches one passed in by params[:room], which the back end receives from the client when the client makes the request to view the line show page. What’s with the :room? Well, we have many lines (queues) running through our LineChannel, and we need to broadcast the correct list of users for a particular line. Note that we could call :room anything we like, so long as it matches up with the params we send from our front end. So we make many rooms within our LineChannel. We then call stream_for @line, which generates (you guessed it again) a stream for @line, that is, our particular line.

Then, when we receive new data — our received(data) method — we want to broadcast the updates to our subscribed users. So, LineChannel.broadcast_to(@line, something-something-something). broadcast_to takes two arguments — firstly, the particular room/line to which we would like to broadcast the updates, and secondly the data that we will be broadcasting. You can send whatever data you like, but you will need to make certain that the front end knows what to do with that data.

A quick note on unsubscribed — it is used for cleanup of streams when a subscriber leaves the channel/room. We did not implement it in QSmart because we found that a single user leaving a particular line show page could stop the stream for all other subscribers on that particular line show page, forcing them to refresh to reestablish the Action Cable subscription.

While we’re back here in the back end, let’s take care of some general configuration stuff. Make sure your cable.yml file looks something like this:

## BACK config/cable.yml
development:
adapter: async
test:
adapter: async
production:
adapter: redis
url: redis://localhost:6379/1
channel_prefix: qsmart-api_production

You will need to change the production URL if you push your app to, say, Heroku later on.

Now, onto routes. You need to mount your Action Cable somewhere, right? Add the following line to your routes file:

## BACK config/routes.rb
mount ActionCable.server, at: ‘/cable’

This will add a route for your front end to connect to your app’s Action Cable. Note that you can mount this wherever you like, but again, it will have to match up on the front end.

In your production environment file, add the following lines:

## BACK config/environments/production.rb
config.action_cable.mount_path = '/cable'
config.action_cable.url = 'wss://localhost:3000/cable'
config.action_cable.allowed_request_origins = [ /http:\/\/localhost:*/ ]

You will need to add your front end domain names (including the different protocols, like http vs. https) to config.action_cable.allowed_request_origins if you push your app to Heroku, as well as changing config.action_cable.url to match your front end.

Great! Let’s move to the front end. Buckle up.

First, you’ll need the actioncable package for React. Assuming you’ve done create-react-app:

## FRONT
npm install actioncable --save
## or, if you use yarn
yarn add actioncable --save`

Bam! Action Cable. That was easy. Just kidding, we’re so far from done.

Let’s start at the top. In index.js, let’s add our actioncable and a bunch of other stuff:

## FRONT src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import {BrowserRouter as Router} from "react-router-dom"
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import 'semantic-ui-css/semantic.min.css';
// Action Cable setup
import actionCable from 'actioncable'
const CableApp = {}
CableApp.cable = actionCable.createConsumer(`ws://${window.location.hostname}:3000/cable`)
// Pass in CableApp as cableApp prop
ReactDOM.render(
<Router>
<App cableApp={CableApp} />
</Router>,
document.getElementById('root'));
registerServiceWorker();

What’d we do? import actionCable from 'actioncable' is simple enough. We then created a constant CableApp, which is an object {}. Then we declare that CableApp.cable = actionCable.createConsumer(`ws://${window.location.hostname}:3000/cable`). createConsumer is a built in method for actionCable; it facilitates our ability to subscribe to a channel hosted at the domain we pass it. Note that the domain begins with ws — WebSocket! If you push to Heroku, you will have to update this domain name to the name of your API, and change the ws to wss.

We’re not done here! Make sure to pass the CableApp constant into <App/> as a prop — so, <App cableApp={CableApp} />. Now we’re done here.

Our App component will have to pass the cableApp prop down to the WebSocket component — oops, spoilers! We’re going to have to make a component for our WebSocket. More on that soon (I hope).

But our App component is also what maintains our state in QSmart.

## FRONT src/App.js
...
class App extends React.Component {
constructor(props){
super(props)
this.state = {
auth: {
isLoggedIn: false,
user: ''
},
joinLine: {
code: "",
error: false,
lineId: null,
redirect: false
},
line: {
line: {},
users: []
}
}
}
...

The App’s state.line identifies which line a subscriber to a particular line show page is viewing, as well as the users in that line. When a user joins the line, leaves the line, or is served — that is, when our front end receives news of an update through our Action Cable — this is the state we will update in order to trigger a re-render of the line show page. So we will also need to pass a callback down to our WebSocket component:

## FRONT src/App.js
// Callback function to setState in App from Line Action Cable
updateAppStateLine = (newLine) => {
console.log('updateAppStateLine: ', this.state.line)
this.setState({
line: {
line: newLine.line,
users: newLine.users
}
})
}

Ever hear of data attributes in React? If not, you’re about to. For reasons still not entirely known to me, you cannot pass our cableApp prop, or this callback function — or anything you want to pass down to your WebSocket component — as ordinary props. You will need to pass them as data attributes:

## FRONT src/App.js
...
render()
return(
    ...

< Route path = "/lines/:id" render={(props)=>(
< LineShowPage
{...props}
data-cableApp={this.props.cableApp}
data-updateApp={this.updateAppStateLine}
data-lineData={this.state.lineData}
data-getLineData={this.getLineData}
getLineData={this.getLineData}
lineData={this.state.line}
authData={this.state.auth}
/>
)} />
    ...
)

Why? I don’t know. If you can explain it to me, I’d really appreciate that. If you can show me another way, I’d appreciate that even more. All I know is I tried, and I tried, and I tried, and this works.

In turn, you will need to pass them from the <LineShowPage /> component to the <LineWebSocket /> component as data attributes as well — note how to access them from within the LineShowPage:

## FRONT src/components/LineShowPage.js
...
class LineShowPage extends React.Component {
...
// Add <LineWebSocket/> and props
render(){
return (

...
       <LineWebSocket
data-cableApp={this.props['data-cableApp']}
data-updateApp={this.props['data-updateApp']}
data-lineData={this.props['data-lineData']}
data-getLineData={this.props['data-getLineData']}
/>
      ...

)
}
}

So, within our LineShowPage, we can access — and pass along — our passed data attributes by calling, e.g., this.props['data-cableApp']. Now we’ve passed our Action Cable data attributes down to the LineWebSocket, so we should probably take a look at that.

## FRONT src/components/LineWebSocket.js
import React from 'react'
class LineWebSocket extends React.Component {
componentDidMount() {
this.props['data-getLineData'](window.location.href.match(/\d+$/)[0])
    this.props['data-cableApp'].line = this.props['data-cableApp'].cable.subscriptions.create({channel: "LineChannel", room: window.location.href.match(/\d+$/)[0]}, {
received: (newLine) => {
console.log(newLine)
this.props['data-updateApp'](newLine)
}
})
}
render() {
return(
<div />
)
}
}
export default LineWebSocket

Oh boy. Things just got weird. First off, don’t worry about window.location.href.match(/\d+$/)[0] — that’s just a hacky way of grabbing the ID number of our line that we needed to use (credit to David Tomczyk). Let’s note a few things about this component:

  1. It renders an empty <div />.
  2. It really does nothing beyond what it does in componentDidMount().

Really. All this does is grab the initial line data for the App component’s state, create the subscription to the Action Cable for 1) the appropriate channel and 2) the appropriate room (which is the id for our room), and tell the component what to do when it receives data over the WebSocket. That’s it. Why is that it? Once the subscription is created, the connection to the WebSocket has been established, and our React components know what to do when they receive new data. Let’s look closely at the part that opens the subscription:

## FRONT src/components/LineWebSocket.js
...
this.props['data-cableApp'].line = this.props['data-cableApp'].cable.subscriptions.create({channel: "LineChannel", room: window.location.href.match(/\d+$/)[0]}, {
received: (newLine) => {
console.log(newLine)
this.props['data-updateApp'](newLine)
}
})
...

Okay, so we access the data-cableApp prop, and use it to create a subscription. What did that data-cableApp prop look like again?

## FRONT src/components/App.js
const CableApp = {}
CableApp.cable = actionCable.createConsumer(`ws://${window.location.hostname}:3000/cable`)

So really, when we say this.props['data-cableApp'].cable.subscriptions.create..., we are calling actionCable.createConsumer(`ws://${window.location.hostname}:3000/cable`).subscriptions.create... We’re creating a subscription! But to what?

## FRONT src/components/LineWebSocket.js
// Let's call "window.location.href.match(/\d+$/)[0]" just plain old
// "ID" for now.
// That is,
// let id = window.location.href.match(/\d+$/)[0]
({channel: "LineChannel", room: id})

Does that sound familiar? Remember this bit, from the back end?

## BACK app/channels/line_channel.rb
class LineChannel < ApplicationCable::Channel
def subscribed
@line = Line.find_by(id: params[:room])
stream_for @line
end
  def received(data)
LineChannel.broadcast_to(@line, {line: @line, users:
@line.waiting_users})
end
  def unsubscribed
end
end

So our front end’s channel: "LineChannel" brings us to the corresponding LineChannel in the back end, and room: id is passed to the subscribed method as params[:room]. Again, we could have called room anything we liked — perhaps id would have made more sense — but they have to match up. This is what ensures that, when we view the current users in a line, we are viewing the Action Cable channel’s room for the correct line.

Now let’s look at that part that opens the subscription again:

## FRONT src/components/LineWebSocket.js
this.props['data-cableApp'].line = this.props['data-cableApp'].cable.subscriptions.create({channel: "LineChannel", room: window.location.href.match(/\d+$/)[0]}, {
received: (newLine) => {
console.log(newLine)
this.props['data-updateApp'](newLine)
}
})

Note that the subscriptions.create function actually took two arguments — the specifications for our subscription, as well as { received: (newLine) {this.props['data-updateApp'](newLine)}. What’s that do? Glad you asked. It tells our LineWebSocket what to do when it receives an update over the WebSocket. In this case, we tell it to call this.props['data-updateApp'](newLine), where we pass in (newLine) as the new data received. This is that callback from App.js we defined earlier:

## FRONT src/App.js
// Callback function to setState in App from Line ActionCable
updateAppStateLine = (newLine) => {
console.log('updateAppStateLine: ', this.state.line)
this.setState({
line: {
line: newLine.line,
users: newLine.users
}
})
}

This callback takes in data as newLine and then updates the App component’s state, triggering a re-render on the LineShowPage component. That’s goddamn amazing, isn’t it?

So we’re done, right? Not so fast. We haven’t told our back end to broadcast data when a person joins or leaves the line! You’d be in for a pretty big disappointment if you thought this would work as-is. Fortunately, we’re nearly there.

When do we want to broadcast to our channel/room? When someone joins or leaves a line. When does that happen? When a new association between a particular line and a particular user is created, updated, or destroyed — which is to say, when a record is altered on our lines_users join table. So let’s open up our lines_users_controller on the back end:

## BACK app/controllers/api/v1/lines_users_controller.rb
class Api::V1::LinesUsersController < ApplicationController
...
def create
...
LineChannel.broadcast_to(@line, {line: @line, users:
@line.waiting_users})
...
end
def update
...
LineChannel.broadcast_to(@line, {line: @line, users:
@line.waiting_users})
...
end
def destroy
...
LineChannel.broadcast_to(@line, {line: @line, users:
@line.waiting_users})
...
end

Now we’re done! We just needed to add LineChannel.broadcast_to(@line, {line: @line, users: @line.waiting_users}) to each applicable controller method.

One final note. I mentioned earlier that you could pass whatever you like as a second argument to the broadcast_to method. This is true, but I should reiterate that you will want the data you broadcast to match up with your front end’s callback, which is called when the WebSocket component receives new data. Again, the callback expects a newLine object:

## FRONT src/App.js
// Callback function to setState in App from Line ActionCable
updateAppStateLine = (newLine) => {
console.log('updateAppStateLine: ', this.state.line)
this.setState({
line: {
line: newLine.line,
users: newLine.users
}
})
}

And we setState with newLine.line and newLine.users. On the back end, we pass the object {line: @line, users: @line.waiting_users}, which perfectly matches the data needed on the front end.

I realize this is a lot to digest, and I truly hope that I did not forget anything along the way. If I did, please let me know! If you’re curious about configuring Action Cable with React for deployment to Heroku, I may cover that in a subsequent post. For now, best of luck, and enjoy your speedy new Action Cables.