DEV Community

Kevin Sassé
Kevin Sassé

Posted on

Using Rails Action Cable with a React Client

For my final bootcamp project, I decided to make use of websockets to create a 1v1 card game using React & Rails. In my research, I had difficulty finding sources that went beyond the functionality of a simple chat application. As a result, the guide below worked for my purposes but there may be more efficient ways to handle this type of connection.

Basic terminology

A consumer is the client connecting to a websocket and is created in a javascript client. A consumer can be subscribed to multiple channels at a time.

A channel "Each channel encapsulates a logical unit of work, similar to what a controller does in a typical MVC setup" - rubyonrails.org

A subscriber is a consumer that has successfully connected to a channel

Getting Started

Starting with Rails, in the Gemfile, uncomment gem "rack-cors" and run bundle install in your terminal. Open the cors.rb file (app/config/initializers/cors.rb) and uncomment this section:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "*"

    resource "*",
      headers: :any,
      methods: [:get, :post, :patch, :delete, :options, :head]
  end
end
Enter fullscreen mode Exit fullscreen mode

For the purposes of this guide, I am setting origins to "*". That is fine if you are working locally but be sure to update it to the URL of your front-end application if you deploy your project.

Next we need to create a route in the routes.rb file and mount the action cable server, your routes file should look like this:

Rails.application.routes.draw do
  # rest of your routes
  mount ActionCable.server => '/cable'
end
Enter fullscreen mode Exit fullscreen mode

Open the cable.yml file, it should look like this:

development:
  adapter: async

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: <your_app_name>_production
Enter fullscreen mode Exit fullscreen mode

This guide only focuses on the development environment.The async adapter will suffice however you will most likely need to use redis for production, please refer to the documentation your hosting service for configuration.

Now let's set up the React app to work with our server. First let's install an npm package that helps connect React with rails. There are many available but I found @rails/actioncable to be the most up-to-date and most used.

In your console, run npm i @rails/actioncable. Once installation is complete, create a new file cable.js in the src folder and add this code:

import { createConsumer } from "@rails/actioncable";

const consumer = createConsumer(`<your_backend_url>/cable`)

export default consumer
Enter fullscreen mode Exit fullscreen mode

Creating a connection

Now that both applications are configured to use ActionCable we can create the websocket connection.

In the Rails app, create a new file in the channels folder app/channels. The name of this file will be the name of the channel itself. If you used a rails generator you will see two methods added by default, subscribed and unsubscribed

For my game, when a new game is started, a random key is generated. I am using that key to identify channel instances, my channel file looks like this:

class GameSessionChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
    game = Game.find_by(game_key: params[:game_key])
    stream_for game
  end

  def unsubscribe
    # Any cleanup needed when channel is unsubscribed
    puts "unsubscribed"
    stop_all_streams

  end
end
Enter fullscreen mode Exit fullscreen mode

In React, the game creates the websocket connection when a user joins a game as part of a useEffect when the Game component first loads:

import consumer from "../cable"

useEffect(() => {
        consumer.subscriptions.create({
                channel: "GameSessionChannel",
                game_key: `${gameSession.game_key}`,
            },{
                connected: () => {
                    console.log("connected")
                    setIsConnected(true)
                },
                disconnected: () => {
                    console.log("disconnected")
                    setIsConnected(false)
                },
                received: (data) => {
                    switch(data.action) {
                        case "attack-declared":
                          // any logic you want to run
                          break;
                        case "defense-declared":
                          // any logic you want to run
                          break;
                        // any other cases needed
                    }
                }
            })
            return () => {
                consumer.disconnect()
            }
    },[])
Enter fullscreen mode Exit fullscreen mode

Let's break this down. First, consumer.subscriptions.create will create the websocket connection. It needs a channel and identifier. The channel must match the class of the Rails channel that will handle this connection, in this case it is "GameSessionChannel". The identifier is the game_key. In the Rails application, I use this game_key to find the Game in the database and set it as the broadcast target for this channel in the subscribe method.

connected, disconnected and received are functions that will run when those conditions are met, i.e when the connection is created/terminated and when the client receives data from the server.

I also include a return callback function to disconnect the websocket when the user navigates away from the page or closes the window.

Broadcasting data

Now that we have the connections set up correctly, we can broadcast data from anywhere within our Rails application.
Let's send a message when a player joins the game.

In our React app, we will send a fetch request to the server with the User information and the Game Key as parameters:

    const handleSubmit = (e) => {
        e.preventDefault()


          fetch(`<your_backend_url>/joingame/${formData.gameKey}`,{
            method: "PATCH",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({opponent_id: currentUser.id}),
          })
          .then(res => {if (res.ok) {
            res.json()
          .then(existingGame => {
            setGameSession(existingGame)
            navigate(`/game/${existingGame.game_key}`)
          })
          } else {
              res.json().then(errors => setErrors(errors))
          }})
Enter fullscreen mode Exit fullscreen mode

The Rails application will then look up the Game in the database, associate the user as the opponent(or host if this user is creating a game) then broadcast to all subscribers that a user has joined:

    def join_game
        game = Game.find_by!(game_key: params[:game_key])
        user = User.find(params[:opponent_id])
        game.update!(opponent_id: params[:opponent_id])
        user.update!(gamesPlayed: user.gamesPlayed + 1)
        GameSessionChannel.broadcast_to game, {action: "user-joined", game: game, message: "Opponent has joined the game"}
             render json: game, status: :accepted
    end
Enter fullscreen mode Exit fullscreen mode

This line is the websocket broadcast from the game channel as defined by the game_key and is sent to all subscribers of that particular game:

GameSessionChannel.broadcast_to game, {action: "user-joined", game: game, message: "Opponent has joined the game"}
Enter fullscreen mode Exit fullscreen mode

The action key matches a case in the React app, which then triggers the state of the gameSession to be updated with the new player's information as well as adding a message to the game log for all players to see.

                        case "user-joined":
                            setGameSession(data.game)
                            setGameLog(gameLog => ([ ...gameLog, data.message]))
                            break;
Enter fullscreen mode Exit fullscreen mode

When I was first setting up broadcasts, I had set all of my cases to console.log(data.message) as an easy test to check if data was coming from the server correctly.

Additional reading:
Rails docs
@rails/actioncable npm package

Top comments (0)