Using Rails Action Cable with a React Client
Tags: rails, react, actioncable, flatironschool
Published: 2023-01-05 19:40:11

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

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

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

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

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

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()
            }
    },[])
```

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))
          }})
```

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


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"}
```

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

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](https://guides.rubyonrails.org/action_cable_overview.html)
[@rails/actioncable npm package](https://www.npmjs.com/package/@rails/actioncable)