Skip to content
Fernando Mendez

WebSockets with React, Redux, and Ruby on Rails

programming, react, ruby, rails, redux2 min read

Note: all the code for this post can be found here(frontend) and here (backend/rails)

Let's explore how to integrate Rails ActionCable functionality (WebSockets) with a basic chat application using React and Redux (via Redux Toolkit). I'm only including the most relevant snippets of code, please refer to the code in the repo for the entire context.

Backend

Since I'm using rails as an API endpoint, I'll create the app using the --api flag. This will prevent views from being generated when we call any of the rails generate commands, hence avoiding unnecessary code. Additionally, we'll use postgresql as the DB.

1rails new chat-app-backend-rails --api -database=postgresql

Since we're building our frontend as a separate standalone project, potentially deployed on a different server than our API, we need to allow for cross domain calls. For that, we first add rack-cors on the Gemfile:

1gem 'rack-cors'

And then configure it on config/initializers/cors.rb.

1Rails.application.config.middleware.insert_before 0, Rack::Cors do
2 allow do
3 # In a prod app you'll restrict to specific origin(s).
4 # for this will just allow from any.
5 origins '*'
6
7 resource '*',
8 headers: :any,
9 methods: %i[get post put patch delete options head]
10 end
11end

We then bundle install to install the gem we added.

Our app will simply have User and Messages. Let's create the models for that:

1rails generate model User
2rails generate model Message

Our User will only have username and status this is what the migration looks like:

1class CreateUsers < ActiveRecord::Migration[7.0]
2 def change
3 create_table :users do |t|
4 t.string :username
5 t.string :status
6 t.timestamps
7 end
8 end
9end

And for the Message:

1class CreateMessages < ActiveRecord::Migration[7.0]
2 def change
3 create_table :messages do |t|
4 t.string :content
5 t.timestamps
6 end
7 end
8end

Our models have a 1-to-many relationship (1 user has many messages). We'll capture that by adding has_many :messages on the User and belongs_to on Message.

1class User < ApplicationRecord
2 has_many :messages, dependent: :destroy
3end
4
5class Message < ApplicationRecord
6 belongs_to :user
7end

Lastly, we'll add a migration that adds the reference (user_id) to messages.

1rails generate migration AddBelongToMessages

With this code:

1class AddBelongToMessages < ActiveRecord::Migration[7.0]
2 def change
3 add_belongs_to :messages, :user
4 end
5end

Note: We could have added this when we first created the Message migration.

Finally, we run the migrate command:

rails db:migrate

Next, let's add all the routes we'll be using and mount the ActionCable (WebSocket) server:

1resources :messages, only: %i[index]
2 resources :users, only: %i[index create] do
3 post 'add_message'
4 post 'change_status'
5 end
6 mount ActionCable.server => '/cable'

That's it for the setup. We're now ready to start adding some functionality. Let's start creating the messages and users channels. We'll use these to listen for messages posted on the chat and for users joining.

1rails generate channel messages
2rails generate channel users

In both generated channels, we'll simply change the subscribed method to specify where we're streaming from:

1class MessagesChannel < ApplicationCable::Channel
2 def subscribed
3 stream_from 'message_channel'
4 end
5
6 def unsubscribed; end
7end
8
9class UsersChannel < ApplicationCable::Channel
10 def subscribed
11 stream_from 'user_channel'
12 end
13
14 def unsubscribed; end
15end

Now we can use the ActionCable.server.broadcast() method to broadcast to all the subscribers on those channels. We want to notify to all subscribers of the user_channel when a user joins the chat. We also want to notify the message_channel after sending messages. Let's do both of those things on the UsersController:

1class UsersController < ApplicationController
2 def index
3 users = User.all
4 render json: users
5 end
6
7 def create
8 user = User.new(user_params)
9 ActionCable.server.broadcast('user_channel', user) if user.save
10 render json: user
11 end
12
13 def add_message
14 user = User.find(params[:user_id])
15 message = params[:message]
16 created_message = user.messages.create(content: message)
17 ActionCable.server.broadcast('message_channel', created_message) if user.save
18 head :ok
19 end
20
21 def change_status; end
22
23 def user_params
24 params.require(:user).permit(:username, :status)
25 end
26end

For completion, we also have our MessagesController that returns all messages for the users who just joined the chat (that way they can see what was said before them joining).

1class MessagesController < ApplicationController
2 def index
3 messages = Message.all
4 render json: messages
5 end
6end

With that, we have all the API calls we need to integrate with our frontend:

1rails routes | grep users
2
3user_add_message POST /users/:user_id/add_message(.:format)
4
5user_change_status POST /users/:user_id/change_status(.:format)
6
7users GET /users(.:format)
8
9POST /users(.:format) users#create
10
11
12
13rails routes | grep messages
14
15messages GET /messages(.:format)

Frontend

For the frontend, I'll be using react with redux and typescript. Let's create the app:

1npx create-react-app chat-app-ui --template redux-typescript

This template will give you an application skeleton that uses redux with toolkit already setup (e.g., a sample reducer, a configured store, etc.).

I'll start by creating a /features/users folder. In there I'll add all the api and reducer functionality. In there I created a usersAPI with all the backend calls related to users. For example, this is how we're adding a new user to the chat:

1export const addNewUser = async (user: UserType): Promise<any> => {
2 const res = await fetch("http://localhost:3090/users", {
3 method: "POST",
4 headers: {
5 "Content-Type": "application/json",
6 },
7 body: JSON.stringify(user),
8 });
9
10 return await res.json();
11};

And this is how we handle a user sending a message:

1export const sendUserMessage = async (
2 data: sendUserMessageDataType
3): Promise<any> => {
4 const res = await fetch(
5 `http://localhost:3090/users/${data.user.id}/add_message`,
6 {
7 method: "POST",
8 headers: {
9 "Content-Type": "application/json",
10 },
11 body: JSON.stringify({
12 user_id: data.user.id,
13 message: data.message.content,
14 }),
15 }
16 );
17
18 return await res.json();
19};

We will use these API calls indirectly via Redux thunks.

When working with async calls in the frontend, we usually make the async call and if it succeeds, we update the application state (e.g., Redux state) with the results. With thunks, the process is the same, but all is handled in the reducer itself. We only have to dispatch an action and after is fulfilled (e.g., call succeeded) then we update the state.

This is what a thunk looks like for adding a new user and for sending messages:

1...
2export const addUserAsync = createAsyncThunk(
3 'users/addUser',
4 async (user: UserType) => {
5 const response = await addNewUser(user);
6 return response;
7 }
8)
9
10export const sendMessageAsync = createAsyncThunk(
11 'users/sendMessage',
12 async (data: sendUserMessageDataType) => {
13 const response = await sendUserMessage(data);
14 return response;
15 }
16)
17...

We then configure them on the extraReducers section of the createSlice().

1...
2 extraReducers: (builder) => {
3 builder
4 .addCase(sendMessageAsync.fulfilled, (state, action) => {
5 let updatedUser: UserType = state.value.filter(user => user.id === action.payload.user.id)[0];
6 updatedUser.messages.push(action.payload.message);
7 state.value = state.value.map(user => user.id !== updatedUser.id ? user : updatedUser)
8 })
9
10 .addCase(addUserAsync.fulfilled, (state, action) => {
11 state.value.push(action.payload);
12 localStorage.setItem("currentUser", JSON.stringify(action.payload));
13 state.userLoggedIn = true;
14 })
15 },
16...

You can review the entire reducer here.

To call Rails's ActionCable we have to install the actioncable package.

1npm install --save actioncable

This is how we're using actioncable in the Messages.tsx to subscribe to new messages posted:

1import { useAppDispatch, useAppSelector } from "../app/hooks";
2import { addMessage, selectMessages } from "../features/messages/messagesSlice";
3import { MessageType } from "../types";
4import Message from "./Message";
5import ActionCable from "actioncable";
6import { useEffect } from "react";
7
8function Messages() {
9 const messages: MessageType[] = useAppSelector(selectMessages);
10 const cable = ActionCable.createConsumer("ws://localhost:3090/cable");
11 const dispatch = useAppDispatch();
12
13 const createSubscription = () => {
14 cable.subscriptions.create(
15 { channel: "MessagesChannel" },
16 { received: (message) => handleReceivedMessage(message) }
17 );
18 };
19
20 const handleReceivedMessage = (message: any) => {
21 dispatch(addMessage(message));
22 };
23
24 useEffect(() => {
25 createSubscription();
26 }, []);
27
28 return (
29 <div className="">
30 {messages.map((message) => (
31 <Message key={message.id} message={message} />
32 ))}
33 </div>
34 );
35}
36
37export default Messages;

We use the same approach on the Users.tsx to subscribe to new users joining the chat.

With everything configured and styled, this is what the entire chat application looks like:

With that, we have an app using WebSockets with React, Redux, and Rails.

© 2022 by Fernando Mendez. All rights reserved.
Theme by LekoArts