How I Built a Chat App with WebSockets

What I learned about WebSockets building a multi-channel chat app with real-time notifications in React and Rails Action Cable.

Action Cable is the Rails way to integrate WebSockets, allowing for bi-directional communication with minimal overhead between client and server. I recently made a group chat app with React, Redux Toolkit and Action Cable to support real-time features while leveraging a Rails API to preserve data. The features include:

Here’s a short demo:

While building this app, I couldn’t find a lot of resources on this subject. The Rails guide lacks clarity for using Rails as API. Tutorials from popular articles do not take advantage of Redux for more integrated state management. This post is my attempt to expand the knowledge base.

This post has three main sections: configuration, send messages (from client) and receive messages (by client). The fundamental concepts for WebSockets/real-time applications are mostly in these sections. However, I think it’s necessary to provide additional guidance on other areas of the application to provide a more complete picture. Additionally, as we encounter Redux Toolkit code snippets in the post, I provide excerpts from its documentation for background as the Toolkit might still be relatively new.

Ok, let’s dive in.

Configuration

Rails Action Cable

Include the redis gem for WebSocket communication; uncomment it in your gemfile and bundle:

# Use Redis adapter to run Action Cable in production 4.0
gem 'redis'

Change the adapter setting in your config/cable.yml to the following:

config/cable.yml
development:
  adapter: redis

Mount the ‘/cable’ endpoint in your routes to receive connection requests from client.

config/routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'
end

Client side connection request will look like this: CableApp.cable = actionCable.createConsumer('ws://localhost:3000/cable')

Create React App with Redux Toolkit

Create a react app with Redux Toolkit; you can delete any unnecessary starter code.

npx create-react-app chat-app-client --template redux

Install the actioncable npm package.

  npm install --save actioncable

Establish Action Cable Connection

Establish one Action Cable connection in src/index.js and provide this connection to your app through Context.

src/index.js
import React, { createContext } from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import actionCable from "actioncable";

import App from "./App";
import store from "./app/store";

const CableApp = {}
// change to whatever port your server uses
CableApp.cable = actionCable.createConsumer("ws://localhost:3000/cable");

export const ActionCableContext = createContext();

ReactDOM.render(
  <Provider store={store}>
    ...
    <ActionCableContext.Provider value={CableApp.cable}>
      <App />
    </ActionCableContext.Provider>
    ...
  </Provider>,
  document.getElementById("root");
);

We create the ActionCableContext here and use it to provide CableApp.cable to App’s component tree. This will allow us to generate subscriptions, through one single connection, to multiple cable channels in the Action Cable server.

Note the url in createConsumer no longer starts with http but ws, denoting WebSocket requests. You might wonder what consumer means here. It’s a term used in Action Cable to refer to the client-side connection to the server. The client-side connection is called a consumer, and the server-side connection is called a connection. The consumer is responsible for creating subscriptions to channels. Refer to Rails guide for the architectural stack explanation. Here’s a much simplified graphical representation:

Action Cable Graphical Presentation

Data Models and Associations in Rails Backend

Before we go into details on sending messages and receiving messages, we should first generate our models and associations. I include it here just for contextual understanding of data relationships so the names used in later sections don’t create confusion.

Note: The membership table is only necessary for preserving data about unread messages; message can serve as a join table if preserving data is not needed.

  rails g resource user name picture_url
  rails g resource room name description
  rails g resource membership last_read_at:datetime user:references room:references
  rails g resource message content user:references room:references

Send Messages from Client

Create Channel in Rails Backend

Generate the messages channel with Rails command line, which creates the app/channels/messages_channel.rb file for us.

rails g channel messages

Set up the generated channel with subscribed, receive and unsubscribed actions, corresponding to the subscribe, send and unsubscribe functions on the client side.

app/channels/messages_channel.rb
class MessagesChannel < ApplicationCable::Channel
  def subscribed
    stop_all_streams
    @room = Room.find(params[:id])
    stream_for @room
  end

  def receive(data)
    ...
  end

  def unsubscribed
    stop_all_streams
  end
end

A channel is similar to a controller in a regular MVC setup. It can handle multiple subscriptions with different identifiers. In addition, the difference between stream_from and stream_for is that the former expects a string while the latter an object. For example: stream_fromchat_#{params[:room]}” vs. stream_for current_user. I find it easier to understand stream_for as it makes the channel more comparable to a controller.

The receive action will be modified in the next section to save messages and broadcast them to client.

Send Messages from React Client

Create a MessagesList component to render the list of messages for the selected chat room. It should have the Action Cable connection provided by ActionCableContext.Provider, which refers to actionCable.createConsumer('ws://localhost:3000/cable').

The MessagesChannel is already created in our Rails backend. As mentioned before, We can create multiple channels on an Action Cable connection; each channel can receive multiple subscriptions from client side. In this example, we create a subscription to the MessagesChannel with the roomId as the identifier. The creation is in a useEffect hook, because we want to trigger the subscription whenever the roomId changes. The roomId identifier will tell our backend which room to stream for.

Additionally, we’re setting the channel as a component state. This is because we need to use the channel to send messages to the backend outside the useEffect hook. We’ll see how to do that in the next step.

Finally, remember to clean up the subscription in the useEffect return function by calling the unsubscribe method provided by Action Cable.

MessagesList.jsx
const MessagesList = () => {
  const { roomId } = useParams();
  const cable = useContext(ActionCableContext);
  const [channel, setChannel] = useState(null);

  ...

  useEffect(() => {
    const channel = cable.subscriptions.create({
      channel: 'MessagesChannel',
      id: roomId,
    });

    setChannel(channel);

    return () => {
      channel.unsubscribe();
    }
  }, [roomId]);

  ...

};

Now we’ve created the subscription, we can use its send method to send data to our backend. The corresponding action in our backend is receive(data).

MessagesList.jsx
const sendMessage = (content) => {
  const data = { roomId, userId, content }
  channel.send(data)
}

This function is called by an input component where the user input their messages and that’s where it gets the content. Additionally, our backend expects roomId and userId. We can use the useParam hook to get the roomId from the url pretty easily if we follow REST conventions. For the userId, we can dispatch an action to add user id to the Redux store when user logs in. This way, we can use a useSelector hook to get current user’s id:

MessagesList.jsx
const currentUserId = useSelector(state => state.users.currentUser);

Receive Messages in Client

Action Cable Broadcasts Data

The backend work for this part is extremely easy as we have already set up our MessagesChannel and associated actions. We only need to build out our receive(data) action so it saves the received messages in our database and broadcasts it back to client.

app/channels/messages_channel.rb
class MessagesChannel < ApplicationCable::Channel
  ...
  def receive(data)
    user = User.find_by(id: data['userId'])
    message = @room.messages.create(content: data['content'], user: user)
    MessagesChannel.broadcast_to(@room, MessageSerializer.new(message).serialized_json)
  end
  ...
end

Note: Remember we have already identified the correct @room in our subscribed action. So we can directly use it in our receive action.

The MessageSerializer is a class that we create to format the data for broadcasting. I used FastJsonApi to construct it:

app/serializers/message_serializer.rb
class MessageSerializer
  include FastJsonapi::ObjectSerializer
  attributes :content, :created_at
  belongs_to :room
  belongs_to :user
end

You can also perform the broadcast later with an Active Job instance, so the data transmission is queued up for better performance. It can be done like this:

app/channels/messages_channel.rb
class MessagesChannel < ApplicationCable::Channel
  ...
  def receive(data)
    ...
    MessageRelayJob.perform_later(message)
  end
  ...
end

And the job:

app/jobs/message_relay_job.rb
class MessageRelayJob < ApplicationJob
  queue_as :default

  def perform(message)
    room = message.room
    MessagesChannel.broadcast_to(room, MessageSerializer.new(message).serialized_json)
  end
end

React Client Receives Messages

We first need to identify where in our client we want to receive messages. As detailed in the previous section, we’re sending messages from the MessagesList component, which is mounted on the user’s active chat room. As users can only send messages from the active chat room, this makes total sense. However, because we want users to see unread message prompts from inactive chat rooms, we need to receive messages in ChatRoomsList, which is mounted on the user’s chat rooms dashboard but not selected in the url. We can created a child component called ChatRoomsListItem to handle the specific functions.

ChatRoomsListItem.jsx
import { messageReceived } from '../messages/messagesSlice'

...

const ChatRoomsListItem = ({ room }) => {
  const dispatch = useDispatch();
  const cable = useContext(ActionCableContext);

  ...

  useEffect(() => {
    cable.subscriptions.create(
      { channel: 'MessagesChannel', id: room.id },
      {
        received: (data) => {
          dispatch(messageReceived(data))
        },
      }
    )
  }, [room, dispatch]);

  ...

  return (
    ...
    <Link to={`/rooms/${room.id}`}>
      {room.name}
    </Link>
  )
}

Like before, we get the Action Cable connection from the ActionCableContext, and establish subscriptions to the MessagesChannel for each of the chat rooms on the dashboard. We don’t need to set a component channel state here because there’s no need to use the subscription outside the useEffect hook. It just receives data and dispatches an action to update our Redux store.

The messageReceived function is from our Redux messagesSlice. It handles an addOne action to add the received message to the store:

src/features/messages/messagesSlice.js
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';

...

const messagesAdapter = createEntityAdapter();

const initialState = messagesAdapter.getInitialState({
  ...
});

...

const messagesSlice = createSlice({
  name: 'messages',
  initialState,
  reducers: {
    messageReceived(state, action) {
      const data = action.payload.data;

      const message = {
        id: data.id,
        ...data.attributes,
        roomId: data.relationships.room.data.id,
        userId: data.relationships.user.data.id,
      };

      messagesAdapter.addOne(state, message);
    },
  },
  extraReducers: {

   ...

  },
});

export const { messageReceived } = messagesSlice.actions;

If you are not familiar with Redux Toolkit, here’s an explanation of createSlice:

It takes care of generating action type strings, action creator functions, and action objects. All you have to do is define a name for this slice, write an object that has some reducer functions in it, and it generates the corresponding action code automatically. The string from the name option is used as the first part of each action type, and the key name of each reducer function is used as the second part.

In our example, the messages name + the messageReceived reducer function generated an action type of {type: "messages/messageReceived"}.

In addition, here’s an explanation of createEntityAdapter:

It provides a standardized way to store your data in a slice by taking a collection of items and putting them into the shape of { ids: [], entities: {} }. Along with this predefined state shape, it generates a set of reducer functions and selectors that know how to work with that data.

The entity adapter, in our case messagesAdapter, provides a set of generated reducer functions for adding, updating, and removing entity instances from the entity state. In our messageReceived reducer, we’re using the addOne reducer function which accepts a single entity and adds it. We’re taking advantage of Redux’s serialization for better performance.

React Client Displays Messages

Now with the newly received message properly stored in our Redux store, we need to display it to our user. There are two scenarios:

  1. If the message belongs to an active room, then we should render it in the MessagesList component. Remember, this component is rendered with the ‘rooms/:roomId’ url and displays the message log for the room as well as an input component.

  2. If the message belongs to an inactive room, then we just need to show an indicator on the ChatRoomsListItem component to let the user know that they’ve received messages in this inactive room. In this example, I’m bolding the chat room’s name and updating the number of unread messages in a badge.

Active Room

We first should create a selector in the messagesSlice to select messages by roomId from our Redux store.

src/features/messages/messagesSlice.js
...

export const { selectAll: selectAllMessages } = messagesAdapter.getSelectors(
  (state) => state.messages
);

export const selectMessagesByRoom = createSelector(
  [selectAllMessages, (state, roomId) => roomId],
  (messages, roomId) => messages.filter((message) => message.roomId === roomId)
);

Again, if you’re not familiar with Redux Toolkit, here’s an explanation of getSelectors:

The adapter object also has a getSelectors function. You can pass in a selector that returns this particular slice of state from the Redux root state, and it will generate selectors like selectAll and selectById.

The createSelector function is re-exported from the Reselect library:

It takes one or more ‘input selector’ functions as argument, plus an ‘output selector’ function.

When we call selectMessagesByRoom(state, roomId), createSelector will pass all of the arguments into each of our input selectors. Whatever those input selectors return becomes the arguments for the output selector.

In our case, we have two input selector functions:

selectAlMessages
(state, roomId) => roomId

Because we pass in (state, roomId) to the selectMessagesByRoom selector, createSelector will pass them into both of the input selector functions. The result of the first input selector function is messages and the second is roomId. Together they become the arguments of the output selector, which is used to filter the messages.

Now we can use this selector to render new messages that are added to our store to the MessagesList component:

MessagesList.jsx
...
import { selectMessagesByRoom } from './messagesSlice'
import { useSelector } from 'react-redux'
import MessageItem from './MessageItem'

const MessagesList = () => {

  ...

  const messages = useSelector((state) => selectMessagesByRoom(state, roomId));

  const renderedMessages =
    messages &&
    messages.map((message) => (
      <MessageItem key={message.id} message={message} />
    ));

  ...

}

Once a component is hooked up to our store, most of the state management logic will live in the Redux slices. The result is that we have a clean component which automatically re-renders when the selected state changes.

Inactive Room

Like before, we need to create a selector to select unread messages by roomId. The difference is that we need to filter out messages that are older than the lastReadAt timestamp. The room entity includes a last_read_at attribute, providing information about when the user looked at this room most recently. It can be null if the user joined the room but has not looked at it. So we can select the unread messages by comparing the message’s created_at attribute with the room’s last_read_at attribute. In the output selector, we use several helper functions from date-fns to help us with the comparison

src/features/messages/messagesSlice.js
...
import { selectRoomById } from '../rooms/roomsSlice';
import { isAfter, parseISO, subYears } from 'date-fns';

export const selectUnreadMessages = createSelector(
  [selectMessagesByRoom, selectRoomById],
  (messages, room) => {
    const lastReadAt = parseISO(room.lastReadAt) || subYears(Date.now(), 1);

    return messages.filter((message) =>
      isAfter(parseISO(message.created_at), lastReadAt)
    );
  }
);

This is all the setup for unread messages. Now we can use the selector in the ChatRoomsListItem component to render the unread messages indicator.

ChatRoomsListItem.jsx
...
import { selectUnreadMessages } from '../messages/messagesSlice'

const ChatRoomsListItem = ({ room }) => {

  ...

  const numOfUnreads = useSelector((state) =>
    selectUnreadMessages(state, room.id)
  ).length;

  const getFontWeight = () => {
    if (location.pathname.slice(7) === room.id) {
      return 'fontWeightRegular';
    }

    return numOfUnreads === 0 ? 'fontWeightRegular' : 'fontWeightBold';
  }

  const renderedNumOfUnreads = () => {
    if (location.pathname.slice(7) === room.id) {
      return 0;
    }

    return numOfUnreads;
  }

  ...

  return (
    // material UI
    <Link to={`/rooms/${room.id}`}>
      <Badge badgeContent={renderedNumOfUnreads()}>
        <Typography>
            <Box fontWeight={getFontWeight()}>{room.name}</Box>
          </Typography>
      </Badge>
    </Link>
  );
};

Note: We don’t want the unread prompt to show up on the active room. So we created some helper functions to figure out if prompts are necessary based on the url.

Conclusion

The mental shift from HTTP to WebSockets is a big one. But once you get the hang of it, it’s not that bad. Understanding this concept will provide a solid foundation for building real-time applications. So I’d say it’s worth the effort.