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.
1
| rails 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
:
And then configure it on config/initializers/cors.rb
.
1
2
3
4
5
6
7
8
9
10
11
| Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
# In a prod app you'll restrict to specific origin(s).
# for this will just allow from any.
origins '*'
resource '*',
headers: :any,
methods: %i[get post put patch delete options head]
end
end
|
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:
1
2
| rails generate model User
rails generate model Message
|
Our User
will only have username
and status
this is what the migration looks like:
1
2
3
4
5
6
7
8
9
| class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :username
t.string :status
t.timestamps
end
end
end
|
And for the Message
:
1
2
3
4
5
6
7
8
| class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.string :content
t.timestamps
end
end
end
|
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
.
1
2
3
4
5
6
7
| class User < ApplicationRecord
has_many :messages, dependent: :destroy
end
class Message < ApplicationRecord
belongs_to :user
end
|
Lastly, we’ll add a migration that adds the reference (user_id
) to messages.
1
| rails generate migration AddBelongToMessages
|
With this code:
1
2
3
4
5
| class AddBelongToMessages < ActiveRecord::Migration[7.0]
def change
add_belongs_to :messages, :user
end
end
|
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:
1
2
3
4
5
6
| resources :messages, only: %i[index]
resources :users, only: %i[index create] do
post 'add_message'
post 'change_status'
end
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.
1
2
| rails generate channel messages
rails generate channel users
|
In both generated channels, we’ll simply change the subscribed
method to specify where we’re streaming from:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| class MessagesChannel < ApplicationCable::Channel
def subscribed
stream_from 'message_channel'
end
def unsubscribed; end
end
class UsersChannel < ApplicationCable::Channel
def subscribed
stream_from 'user_channel'
end
def unsubscribed; end
end
|
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
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| class UsersController < ApplicationController
def index
users = User.all
render json: users
end
def create
user = User.new(user_params)
ActionCable.server.broadcast('user_channel', user) if user.save
render json: user
end
def add_message
user = User.find(params[:user_id])
message = params[:message]
created_message = user.messages.create(content: message)
ActionCable.server.broadcast('message_channel', created_message) if user.save
head :ok
end
def change_status; end
def user_params
params.require(:user).permit(:username, :status)
end
end
|
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).
1
2
3
4
5
6
| class MessagesController < ApplicationController
def index
messages = Message.all
render json: messages
end
end
|
With that, we have all the API calls we need to integrate with our frontend:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| rails routes | grep users
user_add_message POST /users/:user_id/add_message(.:format)
user_change_status POST /users/:user_id/change_status(.:format)
users GET /users(.:format)
POST /users(.:format) users#create
rails routes | grep messages
messages GET /messages(.:format)
|
Frontend#
For the frontend, I’ll be using react with redux and typescript. Let’s create the app:
1
| npx 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:
1
2
3
4
5
6
7
8
9
10
11
| export const addNewUser = async (user: UserType): Promise<any> => {
const res = await fetch("http://localhost:3090/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(user),
});
return await res.json();
};
|
And this is how we handle a user sending a message:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| export const sendUserMessage = async (
data: sendUserMessageDataType
): Promise<any> => {
const res = await fetch(
`http://localhost:3090/users/${data.user.id}/add_message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user_id: data.user.id,
message: data.message.content,
}),
}
);
return await res.json();
};
|
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| ...
export const addUserAsync = createAsyncThunk(
'users/addUser',
async (user: UserType) => {
const response = await addNewUser(user);
return response;
}
)
export const sendMessageAsync = createAsyncThunk(
'users/sendMessage',
async (data: sendUserMessageDataType) => {
const response = await sendUserMessage(data);
return response;
}
)
...
|
We then configure them on the extraReducers
section of the createSlice()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| ...
extraReducers: (builder) => {
builder
.addCase(sendMessageAsync.fulfilled, (state, action) => {
let updatedUser: UserType = state.value.filter(user => user.id === action.payload.user.id)[0];
updatedUser.messages.push(action.payload.message);
state.value = state.value.map(user => user.id !== updatedUser.id ? user : updatedUser)
})
.addCase(addUserAsync.fulfilled, (state, action) => {
state.value.push(action.payload);
localStorage.setItem("currentUser", JSON.stringify(action.payload));
state.userLoggedIn = true;
})
},
...
|
You can review the entire reducer here.
To call Rails’s ActionCable
we have to install the actioncable
package.
1
| npm install --save actioncable
|
This is how we’re using actioncable
in the Messages.tsx
to subscribe to new messages posted:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| import { useAppDispatch, useAppSelector } from "../app/hooks";
import { addMessage, selectMessages } from "../features/messages/messagesSlice";
import { MessageType } from "../types";
import Message from "./Message";
import ActionCable from "actioncable";
import { useEffect } from "react";
function Messages() {
const messages: MessageType[] = useAppSelector(selectMessages);
const cable = ActionCable.createConsumer("ws://localhost:3090/cable");
const dispatch = useAppDispatch();
const createSubscription = () => {
cable.subscriptions.create(
{ channel: "MessagesChannel" },
{ received: (message) => handleReceivedMessage(message) }
);
};
const handleReceivedMessage = (message: any) => {
dispatch(addMessage(message));
};
useEffect(() => {
createSubscription();
}, []);
return (
<div className="">
{messages.map((message) => (
<Message key={message.id} message={message} />
))}
</div>
);
}
export 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.