This post will continue on from our previous post where we take the public messaging system we built in the last post and add Redux to it. That post can be found here: "Part 1: Action Cable Hello World With Rails 7".
If you are building along and starting from here, you can fetch the source code from here and checkout the branch 1-room-channels.
We will start by cloning our previous work in demo-action-cable-hello-world:
# Clone the previous project and checkout the branch `1-room-channels`
$ git clone https://github.com/okeeffed/demo-action-cable-hello-world
$ cd demo-action-cable-hello-world
$ git checkout 1-room-channels
# Add in Redux and a toastify component for demonstrating different messages
$ yarn add @redux/toolkit react-redux react-toastify
# Start the server
$ bin/dev
At this stage, our project is now ready to start working with.
Let's create a few files then add in some requirements for our store:
# Folder for Redux store
$ mkdir -p app/javascript/store
# Main store file
$ touch app/javascript/store/store.ts
# Helper hooks for TypeScript
$ touch app/javascript/store/hooks.ts
# A "channels" reducer to handle our ActionCable channels
$ touch app/javascript/store/channels.ts
Add the following to app/javascript/store/store.ts:
import { configureStore } from "@reduxjs/toolkit";
import { channelsReducer } from "./channels";
export const store = configureStore({
reducer: {
channels: channelsReducer,
},
});
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
For app/javascript/store/hooks.ts:
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
And finally for our reducer app/javascript/store/channels.ts:
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import type { RootState } from "./store";
// Define a type for the slice state
interface channelsState {
subscriptions: Array<string>;
messages: Record<string, string[]>;
}
// Define the initial state using that type
const initialState: channelsState = {
subscriptions: [],
messages: {},
};
export const channelsSlice = createSlice({
name: "channels",
// `createSlice` will infer the state type from the `initialState` argument
initialState,
reducers: {
addChannel: (state, action: PayloadAction<{ id: string }>) => {
state.subscriptions.push(action.payload.id);
},
addMessageToChannel: (
state,
action: PayloadAction<{ id: string; message: string }>
) => {
if (state.messages[action.payload.id] !== undefined) {
state.messages[action.payload.id].push(action.payload.message);
} else {
state.messages[action.payload.id] = [action.payload.message];
}
},
removeChannel: (state, action: PayloadAction<{ id: string }>) => {
state.subscriptions = state.subscriptions.filter(
(subscriptionId) => subscriptionId !== action.payload.id
);
},
},
});
export const { addChannel, addMessageToChannel, removeChannel } =
channelsSlice.actions;
// Other code such as selectors can use the imported `RootState` type
export const selectSubscriptionById = (state: RootState, id: string) =>
state.channels.subscriptions[id] ?? [];
export const selectMessagesById = (state: RootState, id: string) =>
state.channels.messages[id] ?? [];
export const channelsReducer = channelsSlice.reducer;
In our reducer, we have a basic, contrived data structure for our state that will store all live subscriptions (which admittedly we won't really use in this demo) as well as an object that will store all messages for each channel.
In terms of actions, we have three:
addChannel - adds a new channel to the list of subscriptions.
addMessageToChannel - adds a new message to the list of messages for a channel.
removeChannel - removes a channel from the list of subscriptions.
While we will use all three, it is the addMessageToChannel that will handle the actual messaging.
Updating our WebSocket component
We need to update the code in WebSocket.tsx to implement redux and use our new channelsReducer:
You will see that we have updated our ReactDOM.render method to now include our Redux provider.
We have now updated our WebSocket component to use the useAppSelector hook to access the state of our redux store.
We have added a ToastContainer component to our WebSocket component to display notifications.
Our useEffect hook now subscribes to the RoomChannel channel and adds a new subscription to the list of subscriptions. It will also unsubscribe when the component "un-mounts".
We have updated our received callback.
The recieved callback will now change what it does based on the message type that we send. ADD_MESSAGE_TO_CHANNEL will continue to build out our message, however DISPLAY_NOTIFICATION will display a notification.
Although contrived, I am doing this to demonstrate the power of effectively sending payloads that can conform to our expected Redux-style payloads to perform actions based on type.
First, we can demonstrate the messages across different rooms and then we will display a notification.
Seeing multiple chatrooms in action
Start up our dev environment with bin/dev and open up the Rails console in another terminal.
Navigate to http://localhost:3000/rooms/123 and we can confirm our messages still work by sending some messages via the console.
It is important that our broadcast message adheres of the data structure contract that we are expecting on the frontend. With that done correctly, we can see our results:
Successful messaging via redux
Testing our notification
To demonstrate that we can control different behaviors now, let's send another message to our RoomChannel and then display a notification.
If we now check the application, we will see a notification displayed:
Successful notification
Awesome! We are now using ActionCable with Redux to send messages and using the same receiver to display notifications.
Note: In our example, the notifications themselves are not appearing because of Redux. This was a contrived example to demonstrate that you can implement different functionality in our application now that we are following the "type/payload" schema that Redux actions are built on.
Finally, it is time for us to demonstrate multiple chat rooms working with Redux.
Multiple chat rooms
Open four browser windows and have them go to the following URLs:
http://localhost:3000/rooms/123
http://localhost:3000/rooms/123
http://localhost:3000/rooms/1234
http://localhost:3000/rooms
In our example, we want to demonstrate that sending messages to room_channel_123 will only display messages from that room and that the same will happen if we send messages to another room.
For the first example of sending a message to room 123:
Today's post demonstrated how to setup Redux into our Rails application that is using ActionCable for web sockets.
We then demonstrated how to combine Redux with ActionCable to send messages and display notifications as a contrived example of sending powerful message payloads to our sockets.