Author avatar

Manujith Pallewatte

Using WebSockets in Your React/Redux App

Manujith Pallewatte

  • Mar 25, 2020
  • 19 Min read
  • 24,989 Views
  • Mar 25, 2020
  • 19 Min read
  • 24,989 Views
Web Development
Front End Web Development
Client-side Framework
React

Introduction

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

The Chat App

In today's guide, you will create a basic chat app. The app will provide the following features:

  1. Anyone can create and join a new chat room
  2. Upon creating a room, a unique code is created so that others can join
  3. Anyone joining the room must first input a username
  4. On joining a room, the user can see all past messages from the room
  5. Users in a room can chat in realtime

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.

The Chat Server

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

REST API is a standard API that sends requests over HTTP asynchronously from the web app. It supports two endpoints:

  1. /room?name="game of thrones" to create a new chat room and return unique code
  2. /room/{unique-id} to verify a room when given the unique code and send chat log of the room

WebSocket API

A 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.

The Basic React Redux App

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:

  1. HomeComponent contains options for creating and entering a room
  2. ChatRoomComponent provides a simple chat interface

Then 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
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// actions.js
import axios from 'axios';
import { API_BASE } from './config';

export const SEND_MESSAGE_REQUEST = "SEND_MESSAGE_REQUEST"
export const UPDATE_CHAT_LOG = "UPDATE_CHAT_LOG"

// These are our action types
export const CREATE_ROOM_REQUEST = "CREATE_ROOM_REQUEST"
export const CREATE_ROOM_SUCCESS = "CREATE_ROOM_SUCCESS"
export const CREATE_ROOM_ERROR = "CREATE_ROOM_ERROR"


// Now we define actions
export function createRoomRequest(){
    return {
        type: CREATE_ROOM_REQUEST
    }
}

export function createRoomSuccess(payload){
    return {
        type: CREATE_ROOM_SUCCESS,
        payload
    }
}

export function createRoomError(error){
    return {
        type: CREATE_ROOM_ERROR,
        error
    }
}

export function createRoom(roomName) {
    return async function (dispatch) {
        dispatch(createRoomRequest());
        try{
            const response = await axios.get(`${API_BASE}/room?name=${roomName}`)
            dispatch(createRoomSuccess(response.data));
        }catch(error){
            dispatch(createRoomError(error));
        }
    }
}


export const JOIN_ROOM_REQUEST = "JOIN_ROOM_REQUEST"
export const JOIN_ROOM_SUCCESS = "JOIN_ROOM_SUCCESS"
export const JOIN_ROOM_ERROR = "JOIN_ROOM_ERROR"

export function joinRoomRequest(){
    return {
        type: JOIN_ROOM_REQUEST
    }
}

export function joinRoomSuccess(payload){
    return {
        type: JOIN_ROOM_SUCCESS,
        payload
    }
}

export function joinRoomError(error){
    return {
        type: JOIN_ROOM_ERROR,
        error
    }
}

export function joinRoom(roomId) {
    return async function (dispatch) {
        dispatch(joinRoomRequest());
        try{
            const response = await axios.get(`${API_BASE}/room/${roomId}`)
            dispatch(joinRoomSuccess(response.data));
        }catch(error){
            dispatch(joinRoomError(error));
        }
    }
}

export const SET_USERNAME = "SET_USERNAME"

export function setUsername(username){
    return {
        type: SET_USERNAME,
        username
    }
}
javascript
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
// reducers.js

import { CREATE_ROOM_SUCCESS, JOIN_ROOM_SUCCESS, SET_USERNAME} from './actions';

const initialState = {
    room: null,
    chatLog: [],
    username: null
}

export default function chatReducer(state, action) {
    if (typeof state === 'undefined') {
        return initialState
    }

    switch(action.type){
        case CREATE_ROOM_SUCCESS:
            state.room = action.payload;
            break;
        
        case JOIN_ROOM_SUCCESS:
            state.room = action.payload;
            break;

        case SET_USERNAME:
            state.username = action.username;
            break;
    
    }

    return state
}
javascript
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import React, { useState } from 'react';
import logo from './logo.svg';
import './App.css';
import { Provider, useSelector, useDispatch } from 'react-redux'
import store from './store';
import { createRoom, setUsername, joinRoom } from './actions';

function ChatRoom() {
    const [usernameInput, setUsernameInput] = useState("");
    const username = useSelector(state => state.username);
    const dispatch = useDispatch();

    function enterRooom(){
        dispatch(setUsername(usernameInput));
    }

    return (
        <div>
            {!username && 
            <div className="user">
                <input type="text" placeholder="Enter username" value={usernameInput} onChange={(e) => setUsernameInput(e.target.value)} />
                <button onClick={enterRooom}>Enter Room</button>
            </div>  
            }
            {username &&
            <div className="room">
                <div className="history"></div>
                <div className="control">
                    <input type="text" />
                    <button>Send</button>
                </div>
            </div>
            }

        </div>
    )
}

function HomeComponent(){
    const [roomName, setRoomName] = useState("");
    const [roomId, setRoomId] = useState("");
    const currentRoom = useSelector(state => state.room);

    const dispatch = useDispatch();

    return (
            <>
                {!currentRoom && 
                    <div className="create">
                        <div>
                            <span>Create new room</span>
                            <input type="text" placeholder="Room name" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
                            <button onClick={() => dispatch(createRoom(roomName))}>Create</button>
                        </div>
                        <div>
                            <span>Join existing room</span>
                            <input type="text" placeholder="Room code" value={roomId} onChange={(e) => setRoomId(e.target.value)} />
                            <button onClick={() => dispatch(joinRoom(roomId))}>Join</button>
                        </div>
                    </div>  
                }

                {currentRoom && 
                    <ChatRoom />
                }
            </>
    );
}

function App() {
    return (
        <Provider store={store}>
            <div className="App">
                <HomeComponent />
            </div>
        </Provider>
    )
}

export default App;
javascript

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.

Adding WebSockets

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.

Component-level Integration

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { socket } from 'socketUtil.js';
import { useDispatch } from 'react-redux';

function ChatRoomComponent(){
    const dispatch = useDispatch();

    useEffect(() => {
        socket.on('event://get-message', payload => {
            // update messages
            useDispatch({ type: UPDATE_CHAT_LOG }, payload)
        });
        socket.on('event://user-joined', payload => {
            // handling a new user joining to room
        });
    });
    
    // other implementations
}
javascript

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.

Redux Middleware Integration

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.

React Context Integration

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
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
38
39
40
41
42
43
44
45
46
47
// WebSocket.js

import React, { createContext } from 'react'
import io from 'socket.io-client';
import { WS_BASE } from './config';
import { useDispatch } from 'react-redux';
import { updateChatLog } from './actions';

const WebSocketContext = createContext(null)

export { WebSocketContext }

export default ({ children }) => {
    let socket;
    let ws;

    const dispatch = useDispatch();

    const sendMessage = (roomId, message) => {
        const payload = {
            roomId: roomId,
            data: message
        }
        socket.emit("event://send-message", JSON.stringify(payload));
        dispatch(updateChatLog(payload));
    }

    if (!socket) {
        socket = io.connect(WS_BASE)

        socket.on("event://get-message", (msg) => {
            const payload = JSON.parse(msg);
            dispatch(updateChatLog(payload));
        })

        ws = {
            socket: socket,
            sendMessage
        }
    }

    return (
        <WebSocketContext.Provider value={ws}>
            {children}
        </WebSocketContext.Provider>
    )
}
javascript

Note above that two additional functionalities were introduced:

  1. Sending socket messages The emit function is encapsulated within functions with definitive names. For example, sendMessage would essentially emit a socket message as follows.
1
2
event: events://send-message
payload: <message content>
  1. Receiving socket messages All receiving socket messages are mapped to respective Redux actions. Since the WebSocket context will be used inside the Redux provider, it would have access to the Redux 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.

  • The initiation of the WebSocket works as a part of the React cycle. In the case of socket failure, you could easily handle or provide feedback to the user. This can be handled centrally.
  • For a given event, there will only be one event binding. Since each event maps to a Redux action there are no repetitive bindings by individual components. When the app scales, this prevents a lot of head scratches.
  • All socket actions are wrapped in functions allowing firm control over the structure of the payload and validations on the parameters.
  • All socket-related code is available centrally at one location. Although this is on the pros list, in certain conditions (ex: micro-frontends) this could be an issue. But in a majority of cases, this would be an advantage.

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
2
3
4
5
6
7
8
9
10
11
// actions.js

export const SEND_MESSAGE_REQUEST = "SEND_MESSAGE_REQUEST"
export const UPDATE_CHAT_LOG = "UPDATE_CHAT_LOG"

export function updateChatLog(update){
    return {
        type: UPDATE_CHAT_LOG,
        update
    }
}
javascript
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// App.js
import WebSocketProvider, { WebSocketContext } from './WebSocket';

// ....

function App() {
    return (
        <Provider store={store}>
            <WebSocketProvider>
                <div className="App">
                    <HomeComponent />
                </div>
            </WebSocketProvider>
        </Provider>
    )
}

function ChatRoom() {
    const [usernameInput, setUsernameInput] = useState("");
    const [msgInput, setMsgInput] = useState("");

    const room = useSelector(state => state.room);
    const username = useSelector(state => state.username);
    const chats = useSelector(state => state.chatLog);

    const dispatch = useDispatch();
    const ws = useContext(WebSocketContext);

    function enterRooom(){
        dispatch(setUsername(usernameInput));
    }

    const sendMessage = () => {
        ws.sendMessage(room.id, {
            username: username,
            message: msgInput
        });
    }

    return (
        <div>
            <h3>{room.name} ({room.id})</h3>
            {!username && 
            <div className="user">
                <input type="text" placeholder="Enter username" value={usernameInput} onChange={(e) => setUsernameInput(e.target.value)} />
                <button onClick={enterRooom}>Enter Room</button>
            </div>  
            }
            {username &&
            <div className="room">
                <div className="history" style={{width:"400px", border:"1px solid #ccc", height:"100px", textAlign: "left", padding: "10px", overflow: "scroll"}}>
                    {chats.map((c, i) => (
                        <div key={i}><i>{c.username}:</i> {c.message}</div>
                    ))}
                </div>
                <div className="control">
                    <input type="text" value={msgInput} onChange={(e) => setMsgInput(e.target.value)} />
                    <button onClick={sendMessage}>Send</button>
                </div>
            </div>
            }

        </div>
    )
}
javascript

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.

Conclusion

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.

108