WebSockets are a convenient way to create a long-running connection between a server and a client. In an instance where a web client needs to constantly check the server for data, having a REST-based implementation could lead to longer polling times and other related complexities. WebSockets provide a two-way communication stream, allowing pushing of data from the server to the client.
Although using WebSockets is quite straightforward, integrating it into a React+Redux app can be tricky. This guide will use a practical example to explore different patterns of integrating WebSockets to a React app and discuss the pros and cons of each. The entire code is available in Github Repo
In today's guide, you will create a basic chat app. The app will provide the following features:
To keep the app from being overly complicated, features such as user authentication, room listing, and private chats are not implemented, but you are welcome to implement such features to test the learned concepts.
First, this guide will show you how to create a basic React app that provides features 1-4 from the list above. Those four features do not need WebSockets for implementation and can be done using your everyday React tools. This guide will use Redux with Redux Toolkit for state management and Axios for API connectivity. The chat server will support both an HTTP REST API for CRUD operations and WebSockets for socket-related functions.
WebSockets are mainly used for providing bilateral communication between the users and the server. When a user enters a message in a room, these messages should be sent to the server and stored in chat logs so that the users joining later can see, and should be broadcasted to all other users in the room.
It can be seen as a long-polling requirement from the receiving user's perspective because the client needs to constantly query the server for new messages. Thus, this is a perfect opportunity to use the strengths of WebSockets.
To begin with, you need the server component for the overall chat app to function. To manage the scope of this guide implementation of the server won't be discussed in detail, but the server code is available in the Github Repo(Github Repo).
This guide will briefly look at the structure of the server and the features provided by it. The server provides two key APIs.
REST API is a standard API that sends requests over HTTP asynchronously from the web app. It supports two endpoints:
/room?name="game of thrones"
to create a new chat room and return unique code/room/{unique-id}
to verify a room when given the unique code and send chat log of the roomA socket-based API facilitates continuous two-way communication. WebSockets rely on an event-based mechanism over a shared channel rather than individual endpoints. This guide will explore this in the actual implementation.
To keep the server simple, all data will be stored in memory using basic data structures, allowing you to keep your focus on the web app side.
Before introducing the WebSockets to the app, you'll create the rest of the app and set up Redux. The basic app is quite simple and it only has two components:
HomeComponent
contains options for creating and entering a roomChatRoomComponent
provides a simple chat interfaceThen you'll use a single reducer to store the chat logs and other storage elements. Until you add the WebSockets, the actions for sending and receiving messages won't be used. Apart from these two components, the code will follow the standard Redux patterns.
Note We are using React Hooks throughout the guide as recommended by React docs. If you are not familiar with the use of Hooks with Redux, check out this guide, Simplifying Redux Bindings with React Hooks .
1// actions.js
2import axios from 'axios';
3import { API_BASE } from './config';
4
5export const SEND_MESSAGE_REQUEST = "SEND_MESSAGE_REQUEST"
6export const UPDATE_CHAT_LOG = "UPDATE_CHAT_LOG"
7
8// These are our action types
9export const CREATE_ROOM_REQUEST = "CREATE_ROOM_REQUEST"
10export const CREATE_ROOM_SUCCESS = "CREATE_ROOM_SUCCESS"
11export const CREATE_ROOM_ERROR = "CREATE_ROOM_ERROR"
12
13
14// Now we define actions
15export function createRoomRequest(){
16 return {
17 type: CREATE_ROOM_REQUEST
18 }
19}
20
21export function createRoomSuccess(payload){
22 return {
23 type: CREATE_ROOM_SUCCESS,
24 payload
25 }
26}
27
28export function createRoomError(error){
29 return {
30 type: CREATE_ROOM_ERROR,
31 error
32 }
33}
34
35export function createRoom(roomName) {
36 return async function (dispatch) {
37 dispatch(createRoomRequest());
38 try{
39 const response = await axios.get(`${API_BASE}/room?name=${roomName}`)
40 dispatch(createRoomSuccess(response.data));
41 }catch(error){
42 dispatch(createRoomError(error));
43 }
44 }
45}
46
47
48export const JOIN_ROOM_REQUEST = "JOIN_ROOM_REQUEST"
49export const JOIN_ROOM_SUCCESS = "JOIN_ROOM_SUCCESS"
50export const JOIN_ROOM_ERROR = "JOIN_ROOM_ERROR"
51
52export function joinRoomRequest(){
53 return {
54 type: JOIN_ROOM_REQUEST
55 }
56}
57
58export function joinRoomSuccess(payload){
59 return {
60 type: JOIN_ROOM_SUCCESS,
61 payload
62 }
63}
64
65export function joinRoomError(error){
66 return {
67 type: JOIN_ROOM_ERROR,
68 error
69 }
70}
71
72export function joinRoom(roomId) {
73 return async function (dispatch) {
74 dispatch(joinRoomRequest());
75 try{
76 const response = await axios.get(`${API_BASE}/room/${roomId}`)
77 dispatch(joinRoomSuccess(response.data));
78 }catch(error){
79 dispatch(joinRoomError(error));
80 }
81 }
82}
83
84export const SET_USERNAME = "SET_USERNAME"
85
86export function setUsername(username){
87 return {
88 type: SET_USERNAME,
89 username
90 }
91}
1// reducers.js
2
3import { CREATE_ROOM_SUCCESS, JOIN_ROOM_SUCCESS, SET_USERNAME} from './actions';
4
5const initialState = {
6 room: null,
7 chatLog: [],
8 username: null
9}
10
11export default function chatReducer(state, action) {
12 if (typeof state === 'undefined') {
13 return initialState
14 }
15
16 switch(action.type){
17 case CREATE_ROOM_SUCCESS:
18 state.room = action.payload;
19 break;
20
21 case JOIN_ROOM_SUCCESS:
22 state.room = action.payload;
23 break;
24
25 case SET_USERNAME:
26 state.username = action.username;
27 break;
28
29 }
30
31 return state
32}
1import React, { useState } from 'react';
2import logo from './logo.svg';
3import './App.css';
4import { Provider, useSelector, useDispatch } from 'react-redux'
5import store from './store';
6import { createRoom, setUsername, joinRoom } from './actions';
7
8function ChatRoom() {
9 const [usernameInput, setUsernameInput] = useState("");
10 const username = useSelector(state => state.username);
11 const dispatch = useDispatch();
12
13 function enterRooom(){
14 dispatch(setUsername(usernameInput));
15 }
16
17 return (
18 <div>
19 {!username &&
20 <div className="user">
21 <input type="text" placeholder="Enter username" value={usernameInput} onChange={(e) => setUsernameInput(e.target.value)} />
22 <button onClick={enterRooom}>Enter Room</button>
23 </div>
24 }
25 {username &&
26 <div className="room">
27 <div className="history"></div>
28 <div className="control">
29 <input type="text" />
30 <button>Send</button>
31 </div>
32 </div>
33 }
34
35 </div>
36 )
37}
38
39function HomeComponent(){
40 const [roomName, setRoomName] = useState("");
41 const [roomId, setRoomId] = useState("");
42 const currentRoom = useSelector(state => state.room);
43
44 const dispatch = useDispatch();
45
46 return (
47 <>
48 {!currentRoom &&
49 <div className="create">
50 <div>
51 <span>Create new room</span>
52 <input type="text" placeholder="Room name" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
53 <button onClick={() => dispatch(createRoom(roomName))}>Create</button>
54 </div>
55 <div>
56 <span>Join existing room</span>
57 <input type="text" placeholder="Room code" value={roomId} onChange={(e) => setRoomId(e.target.value)} />
58 <button onClick={() => dispatch(joinRoom(roomId))}>Join</button>
59 </div>
60 </div>
61 }
62
63 {currentRoom &&
64 <ChatRoom />
65 }
66 </>
67 );
68}
69
70function App() {
71 return (
72 <Provider store={store}>
73 <div className="App">
74 <HomeComponent />
75 </div>
76 </Provider>
77 )
78}
79
80export default App;
The app at this stage supports creating a room and joining to it through the unique code. Next, focus on adding WebSockets to the mix.
To facilitate socket communications in React, you'll use the de-facto library socket.io-client
. Use the command
npm install -S socket.io-client
to install it.
There are multiple ways of adding WebSocket support to a React app. Each method has its pros and cons. This guide will go through some of the common patterns but will only explore in detail the pattern we are implementing.
In this method, you could assume the WebSockets part as a separate util. You would initiate a socket connection at the AppComponent
init as a singleton and use the socket instance to listen to socket messages that are relevant to the particular component. A sample implementation is as follows:
1import { socket } from 'socketUtil.js';
2import { useDispatch } from 'react-redux';
3
4function ChatRoomComponent(){
5 const dispatch = useDispatch();
6
7 useEffect(() => {
8 socket.on('event://get-message', payload => {
9 // update messages
10 useDispatch({ type: UPDATE_CHAT_LOG }, payload)
11 });
12 socket.on('event://user-joined', payload => {
13 // handling a new user joining to room
14 });
15 });
16
17 // other implementations
18}
As you can see above, this completely segregates the Redux and WebSocket implementations and gives a plug-and-play pattern. This method is useful if you are implementing WebSockets to a few components of an existing app, for example, if you have a blog app and you want to provide real-time push notifications. In that case, you only need WebSockets for the notification component and this pattern would be a clean way to implement. But if the app is socket-heavy, this method would eventually become a burden to develop and maintain. The socket util functions independently and does not work well with React lifecycles. Moreover, the multiple event bindings in each component would eventually slow down the entire app.
Another popular approach is to introduce WebSockets as a middleware to the store. This perfectly harmonizes the WebSocket's asynchronous nature with the one-way data flow pattern of Redux. In the implementation, a WebSocket connection would be initiated in the middleware init, and subsequent socket events would be delegated internally to Redux actions. For example, when the event://get-message
payload reaches the client, the middleware will dispatch the UPDATE_CHAT_LOG
action internally. The reducers would then update the store with the next set of messages. While this is an interesting approach, it won't be discussed at length in this guide. For your reference, this article provides excellent directions on implementation. This method is ideal if a WebSocket is an integral part of the app and tightly coupling with Redux is expected.
The final method is the use of React Context to facilitate WebSocket communication. This guide will go through the implementation and then discuss why this is preferred over the rest. React Context was introduced as a way of managing the app state without passing down props through the parent-child trees. With the recent introduction of Hooks, using Context became trivial.
First, you'll create a Context class for WebSockets that initializes the socket connection.
1// WebSocket.js
2
3import React, { createContext } from 'react'
4import io from 'socket.io-client';
5import { WS_BASE } from './config';
6import { useDispatch } from 'react-redux';
7import { updateChatLog } from './actions';
8
9const WebSocketContext = createContext(null)
10
11export { WebSocketContext }
12
13export default ({ children }) => {
14 let socket;
15 let ws;
16
17 const dispatch = useDispatch();
18
19 const sendMessage = (roomId, message) => {
20 const payload = {
21 roomId: roomId,
22 data: message
23 }
24 socket.emit("event://send-message", JSON.stringify(payload));
25 dispatch(updateChatLog(payload));
26 }
27
28 if (!socket) {
29 socket = io.connect(WS_BASE)
30
31 socket.on("event://get-message", (msg) => {
32 const payload = JSON.parse(msg);
33 dispatch(updateChatLog(payload));
34 })
35
36 ws = {
37 socket: socket,
38 sendMessage
39 }
40 }
41
42 return (
43 <WebSocketContext.Provider value={ws}>
44 {children}
45 </WebSocketContext.Provider>
46 )
47}
Note above that two additional functionalities were introduced:
emit
function is encapsulated within functions with definitive names. For example, sendMessage
would essentially emit a socket message as follows.1event: events://send-message
2payload: <message content>
dispatch
method. At a glance, this implementation would seem like overkill and too similar to the first method discussed above. But a few key differences add great value in the long run.
With these features, the context-based integration is a good fit for scalability as well as the maintainability of the codebase. Now that you have the WebSocketContext
created, you can explore how to use it functionally.
1// actions.js
2
3export const SEND_MESSAGE_REQUEST = "SEND_MESSAGE_REQUEST"
4export const UPDATE_CHAT_LOG = "UPDATE_CHAT_LOG"
5
6export function updateChatLog(update){
7 return {
8 type: UPDATE_CHAT_LOG,
9 update
10 }
11}
1// App.js
2import WebSocketProvider, { WebSocketContext } from './WebSocket';
3
4// ....
5
6function App() {
7 return (
8 <Provider store={store}>
9 <WebSocketProvider>
10 <div className="App">
11 <HomeComponent />
12 </div>
13 </WebSocketProvider>
14 </Provider>
15 )
16}
17
18function ChatRoom() {
19 const [usernameInput, setUsernameInput] = useState("");
20 const [msgInput, setMsgInput] = useState("");
21
22 const room = useSelector(state => state.room);
23 const username = useSelector(state => state.username);
24 const chats = useSelector(state => state.chatLog);
25
26 const dispatch = useDispatch();
27 const ws = useContext(WebSocketContext);
28
29 function enterRooom(){
30 dispatch(setUsername(usernameInput));
31 }
32
33 const sendMessage = () => {
34 ws.sendMessage(room.id, {
35 username: username,
36 message: msgInput
37 });
38 }
39
40 return (
41 <div>
42 <h3>{room.name} ({room.id})</h3>
43 {!username &&
44 <div className="user">
45 <input type="text" placeholder="Enter username" value={usernameInput} onChange={(e) => setUsernameInput(e.target.value)} />
46 <button onClick={enterRooom}>Enter Room</button>
47 </div>
48 }
49 {username &&
50 <div className="room">
51 <div className="history" style={{width:"400px", border:"1px solid #ccc", height:"100px", textAlign: "left", padding: "10px", overflow: "scroll"}}>
52 {chats.map((c, i) => (
53 <div key={i}><i>{c.username}:</i> {c.message}</div>
54 ))}
55 </div>
56 <div className="control">
57 <input type="text" value={msgInput} onChange={(e) => setMsgInput(e.target.value)} />
58 <button onClick={sendMessage}>Send</button>
59 </div>
60 </div>
61 }
62
63 </div>
64 )
65}
As shown above, the use of context-based integration is clean. The WebSocket context can be accessed anywhere in the app using the useContext
Hook, and all the included functionality will be available. Additionally, a real-world app would also need to handle the instances of socket disconnecting and reconnecting, and handling client exit. These instances can easily be added into the WebSocketContext
, leaving the rest of the app clean.
In this guide, we discussed the different practical approaches to integrating WebSockets in an existing React/Redux web app. We briefly outlined three different patterns and explored their strengths and weaknesses. However, for a scalable and maintainable implementation, the React Context-based approach is preferred.