In Part 1 of this tutorial, we used PubNub for the real time communication between two users. In this part, we will use the same React chat component but will trade out the PubNub code with Action Cable code. The main difference here is we will need to save messages to our own database instead of relying on external data storage. The React chat component can be reused with minimal changes to integrate with Action Cable. We will start out with setting up Action Cable on the server side. We will then add a Message model and create the association. Lastly, we will modify the frontend code to make the Action Cable connections.

If you’ve skipped Part 1 of this tutorial, keep in mind that there will be some references to the PubNub version of this chat component. Please refer to Part 1 for more details on the chat component as this article will not repeat what was previously created.

Setting up Action Cable

Let’s start with setting up the Action Cable connection. Open connection.rb in your editor and add the following:

# app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = env['warden'].user
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

Whenever the app starts up, a single WebSocket connection object is instantiated and from there on, all channel subscriptions within that app can be created. When this connection happens, certain credentials need to be verified. First, identified_by sets an “identifier” to the current user, according to the documentation, so that the connection can be found later if needed. Then, in order for the connection to be authorized, we need to verify that a user is logged-in and set that user as current_user. The find_verified_user private method is called from within connect to do this. This app uses Devise for authentication so we need to access the Devise user object, env['warden'].user, to see if there is a currently logged-in user and return this object. Otherwise we return reject_unauthorized_connection to prevent the WebSocket connection object from being instantiated and thus no connection is made.

Next, we’ll setup the chat channel:

# app/channels/chat_channel.rb

class ChatChannel < ApplicationCable::Channel
  def subscribed
    set_trade_offer
    stream_for @trade_offer
  end

  def receive(data)
    message = @trade_offer.messages.create!(content: data["content"])

    ChatChannel.broadcast_to(@trade_offer, message)
  end

  private

  def set_trade_offer
    @trade_offer = TradeOffer.find(params[:room])
  end
end

The chat channel subscription is based on the current trade offer object that the user is viewing. In the subscribed method, set_trade_offer is called to pull the current trade_offer from the database. This is done by providing the :room key available in the cable channel params hash. This params hash is created on the client side when subscriptions.create is called with channel and room passed in as a hash argument. We will write this code soon. The TradeOffer object is then passed to the stream_for method to set up the subscription stream.

The receive method is called when a new message is published in the chat channel. We don’t have a messages controller because messages are only created in the context of a trade offer. Since cable channels work similarly to controllers, we can simply save messages directly to the trade offer instance in the receive method. TradeOffer will have a has_many association to messages as we’ll see next. The broadcast_to method is called on ChatChannel with the trade offer instance and the message object passed in. This will of course broadcast the message only to that trade offer. That is all that is needed on the server side for Action Cable.

Message Model

This version will have a messages model. Run the following:

rails g model message content:text trade_offer:references

Review the migration file and if it looks good, run rails db:migrate. Open up the message.rb file and add the validation and association:

# app/models/message.rb

class Message < ApplicationRecord
  validates :content, presence: true

  belongs_to :trade_offer
end

Now add the has_many association to TradeOffer:

# app/models/trade_offer.rb

class TradeOffer < ApplicationRecord
  belongs_to :user
  has_many :messages, dependent: :destroy

  validates_presence_of :user
end

Here is what you’ll need in the trade offer controller:

# app/controllers/trade_offers_controller.rb

class TradeOffersController < ApplicationController
  def show
    @reciprocal_trade_offer = @trade_offer.reciprocal_trade_offer

    # Need to know the id of the initiating trade offer so that the React Chat
    #   component room is always the same regardless of which trade
    #   offer is clicked on -- initial or reciprocal.
    if @reciprocal_trade_offer.nil?
      initial_trade_offer_id = @trade_offer.id
    else
      initial_trade_offer_id = [@trade_offer.id, @reciprocal_trade_offer.id].min
    end

    messages = @trade_offer.messages.last(20)

    # Initial state for react_on_rails component
    @chat_props = { room:       initial_trade_offer_id,
                    messages:   messages,
                    newMessage: ""
                  }

    redux_store("chatStore", props: @chat_props)
  end
end

There are two main differences in this trade_offers#show action compared to the previous version. Since we’ll be saving messages to our database, we need to run a query to pull any saved messages so that the chat component will render with previous messages if there are any. In this example, we query the last 20 messages, set the array returned to a local variable and pass that in to the messages key in @chat_props. In the PubNub version, @chat_props[:messages] was simply provided an empty array for initial state. Here, we provide an empty array if no messages have been created or an array of messages if they exist.

The second difference is we use the term “room” for our Action Cable chat room and pass in the initial_trade_offer_id as an integer type instead of using string interpolation as in the previous example.

Client-Side

We will now go over the client-side code. As mentioned before, most of the chat component code will remain the same, there will just be some changes in how we handle the data. The examples in the Rails Action Cable documentation use the app/assets/javascripts/cable.js for javascript and CoffeeScript for the subscriptions. The unique approach we’ll be taking won’t use these files or the asset pipeline at all which means we need to add the Action Cable npm package by running yarn add actioncable.

Container Component

We’ll start with a minor change in the Chat container component:

// app/javascript/bundles/Chat/containers/ChatContainer.jsx

import React from 'react'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'

import Chat from '../components/Chat'
import * as actionCreators from '../actions/chatActionCreators'

const mapStateToProps = (state) => ({
  room: state.room,
  messages: state.messages,
  newMessage: state.newMessage
})

const mapDispatchToProps = (dispatch) => ({
  actions: bindActionCreators(actionCreators, dispatch),
})

// Don't forget to actually use connect!
// Note that we don't export Chat, but the redux "connected" version of it.
// See https://github.com/reactjs/react-redux/blob/master/docs/api.md#examples
export default connect(mapStateToProps, mapDispatchToProps)(Chat)

The only thing we do here is change the prop channel to room mostly to play well with the naming convention that Action Cable expects.

Presentation Components

Let’s now go through the chat presentation components. Import ActionCable where we imported PubNub in Part 1:

// app/javascript/bundles/Chat/components/Chat.jsx

import ActionCable from 'actioncable'

Change the prop types from channel to room and the type string to number:

// app/javascript/bundles/Chat/components/Chat.jsx

export default class Chat extends React.Component {
  static propTypes = {
    room: PropTypes.number.isRequired,
    messages: PropTypes.array.isRequired,
    newMessage: PropTypes.string.isRequired,
    actions: PropTypes.object.isRequired
  }

  // Rest of Chat component code
}

We will not need a fetchHistory() method to bind to the Chat instance. Instead, add ActionCable.createConsumer() and set subscription to false in the constructor:

// app/javascript/bundles/Chat/components/Chat.jsx

export default class Chat extends React.Component {
  constructor(props) {
    super(props)
    this.onNewMessageChange = this.onNewMessageChange.bind(this)
    this.autosize = this.autosize.bind(this)
    this.onNewMessageKeyPress = this.onNewMessageKeyPress.bind(this)
    this.onSubmitClick = this.onSubmitClick.bind(this)
    this.submitNewMessage = this.submitNewMessage.bind(this)
    this.cable = ActionCable.createConsumer('/cable')
    this.subscription = false
  }

Let’s go through the methods starting with componentDidMount lifecycle method:

// app/javascript/bundles/Chat/components/Chat.jsx

componentDidMount() {
  this.subscription = this.cable.subscriptions.create({
    channel: "ChatChannel",
    room: this.props.room
  }, {
    received: (data) => {
      this.props.actions.incomingMessage(data)
    }
  })

  this.messagesDiv.scrollTop = this.messagesDiv.scrollHeight
}

There is quite a bit of less code here than the PubNub version. We create a new Action Cable subscription instance and set it to this.subscription variable that was instantiated in the constructor as false. We also pass in an object with the name of the channel "ChatChannel" as a string and the room created in the trade offer controller and then passed down as props.

Next is the submitNewMessage() method:

// app/javascript/bundles/Chat/components/Chat.jsx

submitNewMessage(text) {
  this.subscription.send({
    content: text
  })

  this.props.actions.submitMessage()
  this.textArea.focus()
}

Here, we don’t declare a constant and assign it a message object. Instead, we just pass in the text value to the object passed to subscription.send and assign it to the content property. subscription.send will handle the message broadcast to other clients listening in on this channel. In this case, it will only be the other trade offer user. Also notice that we don’t create an id property and assign it the current date-time. This is because we will be relying on the ID created by Rails when we save our messages in the database. The submitMessage() action call and text area focus are the same as the previous version.

Let’s now go over the MessageList code. The propTypes will look quite a bit different:

// app/javascript/bundles/Chat/components/ChatMessageList.jsx

MessageList.propTypes = {
  messages: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.number.isRequired,
      content: PropTypes.string.isRequired,
      created_at: PropTypes.string.isRequired,
      updated_at: PropTypes.string.isRequired,
      trade_offer_id: PropTypes.number.isRequired
    }).isRequired
  ).isRequired
}

You see here that each message in the messages array has a flatter shape. You probably will notice that the shape is the same as the message object retrieved from the database. Due to this different object shape, the MessageList() function changes slightly:

// app/javascript/bundles/Chat/components/ChatMessageList.jsx

const MessageList = (props) => {
  if (props.messages.length > 0) {
    return props.messages.map(m => {
      return (
        <div id="message" key={m.id}>
          {m.content}
          {timeOfMessage(m.created_at)}
        </div>
      )
    })
  }
  else {
    return <div id="message">No messages</div>
  }
}

The main difference here is we no longer have the entry object level that is created by the PubNub response in the previous tutorial. We also use the Active Record created_at attribute to pass to the timeOfMessage() function for displaying the date or time of the message.

Redux

The chat action creators have changed a little:

// app/javascript/bundles/Chat/actions/chatActionCreators.jsx

import {
  CHANGE_NEW_MESSAGE,
  SUBMIT_NEW_MESSAGE,
  INCOMING_NEW_MESSAGE
} from '../constants/chatConstants'

export const changeNewMessage = (newValue) => ({
  type: CHANGE_NEW_MESSAGE,
  newValue
})

export const submitMessage = () => ({
  type: SUBMIT_NEW_MESSAGE
})

export const incomingMessage = (message) => ({
  type: INCOMING_NEW_MESSAGE,
  message
})

The addHistory() action creator has been deleted since we are no longer pulling historical messages from PubNub. Since we no longer need the addHistory() action creator, don’t forget to delete the corresponding constant from the chatConstants.jsx file. The incomingMessage() action creator has been modified to remove the payload and entry objects. All that is required now is the message object itself.

On to the reducers. Let’s map out the modified state shape to aid us with object management. This version will have less nesting than the PubNub version:

// app/javascript/bundles/Chat/reducers/chatReducer.jsx

/*
State Shape:

{
  actions: {
    changeNewMessage,
    submitMessage,
    incomingMessage
  },
  room: 1,
  messages: [
    {
      id: 1,
      content: 'hello world',
      created_at: '2018-02-16T05:12:45.972Z',
      updated_at: '2018-02-16T05:12:45.972Z',
      trade_offer_id: 1
    },
    {
      id: 2,
      content: 'I love redux',
      created_at: '2018-02-17T17:18:15.006Z',
      updated_at: '2018-02-17T17:18:15.006Z',
      trade_offer_id: 1
    }
  ],
  newMessage: ''
}
*/

Let’s go over the differences. Again, the addHistory action is no more, so that is removed. Instead of a channel property with a string value representing "trade_offer_#{initial_trade_offer_id}", we have a room property with a number value representing the initial_trade_offer_id assigned in the TradeOffer#show action.

The messages array is a bit more simplified with no object nesting for each message element. The message object properties and values match the Rails database schema where the messages are created. That’s it for the state shape differences. Now for the actual reducers:

// app/javascript/bundles/Chat/reducers/chatReducer.jsx

import { combineReducers } from 'redux'

import {
  CHANGE_NEW_MESSAGE,
  SUBMIT_NEW_MESSAGE,
  INCOMING_NEW_MESSAGE
} from '../constants/chatConstants'

const room = (state = 0, action) => {
  return state
}

const messages = (state = [], action) => {
  switch (action.type) {
    case INCOMING_NEW_MESSAGE:
      return [
        ...state,
        action.message
      ]
    default:
      return state
  }
}

const newMessage = (state = '', action) => {
  switch (action.type) {
    case CHANGE_NEW_MESSAGE:
      return action.newValue
    case SUBMIT_NEW_MESSAGE:
      return state = ''
    default:
      return state
  }
}

const railsContext = (state = {}, action) => {
  return state
}

const chatReducer = combineReducers({
  room,
  messages,
  newMessage,
  railsContext
})

export default chatReducer

There’s not a whole lot changed here. Rename the channel() reducer to room() with the initial state set to 0. The messages() reducer is simply changed to accommodate the different state shape for the INCOMING_NEW_MESSAGE case. action.message is called to push the new message object on the new array copy. Again, the addHistory() reducer is not needed and needs to be deleted. Otherwise, no other changes need to be made to the reducers. That was easy.

That is it for the React and Redux code changes. The chat store and remaining React on Rails code do not require to be changed.

Conclusion

This wraps up Part 2 of the two part series tutorials. We have demonstrated two different ways to add real-time components to a Rails app. The PubNub version is a great alternative to Action Cable if you don’t need nor want to add another model to your app. It comes in handy if real-time data doesn’t need to be persisted. Action Cable on the other hand may be better suited if you want to use the Rails provided real-time messaging framework and/or persist the data to your own database. If one has an advantage over the other in terms of scalability or performance, that is outside the scope of these tutorials. I would say that they are most likely comparable in this regard for most applications.