Back to home

Action Cable Messages With Redux main image

Action Cable Messages With Redux

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.

Prerequisites

  1. Basic familiarity with setting up a new Rails project.
  2. Basic familiarity with Redux Toolkit.

Getting started

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.

Creating our store

Effectively we will follow along with the Redux TypeScript Quickstart.

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:

  1. addChannel - adds a new channel to the list of subscriptions.
  2. addMessageToChannel - adds a new message to the list of messages for a channel.
  3. 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:

import * as React from "react"; import * as ReactDOM from "react-dom"; import { BrowserRouter, Routes, Route, useParams } from "react-router-dom"; import { useAppSelector, useAppDispatch } from "../store/hooks"; import { addMessageToChannel, addChannel, selectMessagesById, removeChannel, } from "../store/channels"; import consumer from "../channels/consumer"; import { store } from "../store/store"; import { Provider } from "react-redux"; import { ToastContainer, toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; function WebSocket() { const params = useParams(); const messages = useAppSelector((state) => selectMessagesById(state, params.room_id) ); const dispatch = useAppDispatch(); const roomId = params.room_id ?? "root"; React.useEffect(() => { const subscription = consumer.subscriptions.create( { channel: "RoomChannel", room_id: roomId }, { received({ message }) { switch (message.type) { case "ADD_MESSAGE_TO_CHANNEL": dispatch( addMessageToChannel({ id: roomId, message: message.payload }) ); break; case "DISPLAY_NOTIFICATION": toast(message.payload); break; default: break; } }, } ); dispatch(addChannel({ id: roomId })); return () => { subscription.unsubscribe(); dispatch(removeChannel({ id: roomId })); }; }, []); return ( <> <div> {messages.map((message: string, index: number) => ( <p key={`message-${index}`}>{message}</p> ))} </div> <ToastContainer /> </> ); } function NotFound() { return <p>Not Found</p>; } document.addEventListener("DOMContentLoaded", () => { const rootEl = document.getElementById("root"); ReactDOM.render( <Provider store={store}> <BrowserRouter> <Routes> <Route path="rooms" element={<WebSocket />}> <Route path=":room_id" element={<WebSocket />} /> </Route> <Route path="*" element={<NotFound />} /> </Routes> </BrowserRouter> </Provider>, rootEl ); });

We have updated the component for the following:

  1. You will see that we have updated our ReactDOM.render method to now include our Redux provider.
  2. We have now updated our WebSocket component to use the useAppSelector hook to access the state of our redux store.
  3. We have added a ToastContainer component to our WebSocket component to display notifications.
  4. 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".
  5. 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.

irb(main):001:0> ActionCable.server.broadcast 'room_channel_123' , message: { type: "ADD_MESSAGE_TO_CHANNEL", payload: "Hello, wo rld!" } [ActionCable] Broadcasting to room_channel_123: {:message=>{:type=>"ADD_MESSAGE_TO_CHANNEL", :payload=>"Hello, world!"}} => 1 irb(main):002:0> ActionCable.server.broadcast 'room_channel_123' , message: { type: "ADD_MESSAGE_TO_CHANNEL", payload: "Our chat is happy" }

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

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.

irb(main):003:0> ActionCable.server.broadcast 'room_channel_123' , message: { type: "DISPLAY_NOTIFICATION", payload: "Hello, worl d!" }

If we now check the application, we will see a notification displayed:

Successful notification

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:

  1. http://localhost:3000/rooms/123
  2. http://localhost:3000/rooms/123
  3. http://localhost:3000/rooms/1234
  4. 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:

irb(main):001:0> ActionCable.server.broadcast 'room_channel_123' , message: { type: "ADD_MESSAGE_TO_CHANNEL", payload: "hello room 123" }

If we check our windows, we can see only the two that are on room 123 receive the message:

Room 123 receiving the messages

Room 123 receiving the messages

Finally, let's message our standard room and see that it receives the message:

irb(main):002:0> ActionCable.server.broadcast 'room_channel' , message: { type: "ADD_MESSAGE_TO_CHANNEL", payload: "hello room" }

Basic room route receiving the messages

Basic room route receiving the messages

Great success.

Summary

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.

Resources and further reading

Photo credit: sharonmccutcheon

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on workingoutloud.dev, Den Dribbles and LandPad .

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.