WebSocket and React: WSContext Components Subscription Pattern

A Practical Guide with Examples

francescocartelli
Published in
9 min readAug 25, 2023

--

Introduction

In the dynamic world of web development, creating engaging user experiences in real-time is crucial. While conventional HTTP requests are great for static content, they lack interactivity. This is where WebSocket protocol comes into play, allowing two-way communication over one TCP connection. When paired with React, Websocket open doors to crafting real-time applications.

Challenges

In the domain of real-time communication within React applications, common challenge emerges when handling WebSocket (WS) implementations. While the idea of setting up separate connections for each component seeking real-time updates is tempting, this route can quickly lead to a convoluted network of active connections.

Managing multiple ongoing connections for every potential component listener not only induces unnecessary network load, but also limits scalability and performance as your application expands. What’s more, indiscriminately dispatching messages to all components may trigger unanticipated updates, disrupting the user experience.

Addressing this challenge efficiently requires a multifaceted strategy. One thoughtful approach involves centralizing WebSocket management, using a mechanism to dynamically subscribe components to specific message channels. This not only reduces the number of open connections, but also ensures that messages are routed only to the components that need them, saving unnecessary updates and enabling a more streamlined, responsive, and resource-efficient real-time experience.

This article will delve into the power of the WSContext pattern, exploring how it addresses these complexities and enables you to build responsive, real-time React applications with great precision.

The Pattern

The WebSocketContext pattern operates on a foundation of few principles. It encompasses key components like WebSocketProvider and leverages dynamic subscriptions to tailor message delivery.

Core Principles

  • Single Connection Centralized WS service: Instead of creating multiple WebSocket connections for different parts of your application, this pattern promotes the use of a single connection. All communication logic, including initialization, message handling, and callback registration, is centralized within a single component or context. This centralization ensures that the communication system is consistent, organized, and easy to manage.
  • Integration with Global State Management: Context allows you to manage the connection and associated state in a centralized manner. Instead of drilling down the WebSocket instance or connection details through component props, you can encapsulate this logic in the context provider. This promotes a cleaner and more organized code structure.
  • Subscription Mechanism: By using a context, you can decouple components from the details of WebSocket management. Components can focus on using the two provided subscribe and unsubscribe functions without needing to understand the intricate details of initialization and message handling.
  • Messages Handling with Multiple Components: By using a context, a centralized callback registry is maintained within the channels object. This object maps different channels (or types of messages) to their respective callback functions. Each callback represents a component's ability to process messages of a specific type or from a specific chat channel. This approach allows you to create complex communication logic between components. For instance, you can have different components listening to different channels, reacting to various types of messages, and even implementing fallback behavior when a specific chat channel is not available because the component needed to process it is not rendered.

Core Components

  1. WebSocket Context: React context that is used to manage the WebSocket communication logic. It provides a way to expose the subscribe and unsubscribe functions to any component that is a descendant of the WSProvider component.
  2. WebSocket Provider: It’s a React component that wraps its children with the communication logic and exposes the WebSocket-related functions through the context. This component encapsulates the “Single Connection Centralized WebSocket Service” concept.
  3. WebSocket Messages Consumers: Those are the components of the UI which responsible for message consumption and rendering. Those components only interact with the provider by means of subscribe and unsubscribe functions.

Implementing the Pattern: Real-Time Chat Application

Imagine a real-time chat application where users can participate in multiple chat rooms. To facilitate real-time communication, establishing a single connection for all the chats seems efficient. However, accommodating two distinct use cases adds a layer of complexity.

When a new message arrives from the WebSocket server, the application needs to determine its appropriate display behavior. The app first verifies if the chat room to which the message belongs is currently rendered or visible in the user interface. This verification leads to two possible scenarios:

  1. Chat is Rendered: If the chat room is currently rendered (i.e., the incoming message’s chat identifier matches the identifier of a rendered chat), the message is sent directly to that specific chat’s user interface. This ensures that the message appears smoothly in the ongoing conversation.
  2. Chat is Not Rendered: If the chat room is not currently rendered (i.e., the incoming message’s chat identifier doesn’t match any rendered chat), the message is routed to a generic notification mechanism. This mechanism could involve displaying a notification to inform the user of the new message, even if they are not actively participating in the corresponding chat.

Technical Requirements:

  • Message Display Strategy: The WebSocket connection needs to intelligently differentiate between delivering messages as real-time notifications or directly within the chat, based on user engagement.
  • Component Visibility Logic: The WebSocket management must be aware of the visibility status of chat components. Messages received during times when the chat component is not displayed should be treated as notifications.
  • Minimizing Redundancy: Delivering messages both as notifications and within the chat could lead to redundancy and a less than optimal user experience.
Animation illustrating the final results in the complete app available in the Github Repo

Getting Started

Disclaimer: In the example below, only the steps necessary to implement the pattern will be illustrated. If you want to repeat the example by looking at a complete app (client + servers) you can check my repository: nyla-instant-messaging-app.

The example implementation assumes that you are working within a specific environment:

  • React App: The implementation assumes that you have already set up a React application using a tool like npx create-react-app.
  • WS Library: The example assumes that you have downloaded the ws library, which can be obtained with npm install ws.
  • WS Server: The pattern only involves working the client, assuming the presence of a WebSocket server.
  • Default App.js file: The context assumes the presence of a default App.js file.

In the given example, the initial App.js is structured as follows:

In the snippet above, few components are present:

  1. NotificationsWrapper: Is a container used for displaying the notifications (newly incoming messages) in the app foreground, it is a messages consumer. This component will be added and implemented later.
  2. Chat: Chat component is present to showcase incoming chat messages. This Chat component is placed within a react-router-dom Route at /chats/:id location allowing a conditional rendering. This component will be added and implemented later.
  3. Router, Routes and Route: Their only purpose is to enable conditional rendering.

WS Messages

Consider that the messages sent from the server to the client are structured as JSON and adhere to the following format.

/* WS message example for a create message as JSON object */
/* consider type static in this example */
/* only consider message creation */
{
type: "MESSAGE_CREATE",
message: {
id: "64e77bb0680b783fc08baea0",
chat: "64e77c149b1f8c832f92e4d0",
sender: "64e77c0b545b5cb9a4c18760",
content: "Are you ok?"
createdAt: "2023-08-19T09:18:19.900Z"
}
}

WSContext.js

The first step in the implementation is to create WebSocketContext and WebSocketProvider components (in a file that for simplicity is called WSContext.js). The snippet below shows the implementation for a context provider component (WebSocketProvider) that encapsulates the connection and provides methods for consumer components. Those methods allow to subscribe and unsubscribe from specific message channels.

Here’s a breakdown of the snippet:

  • WebSocketProvider Component: The WebSocketProvider component is defined. This component will wrap its children with the communication logic and make it available to them via the context.
  • State Management with useRef: The useRef hook is used to maintain mutable references to certain values (ws and channels) that persist across re-renders of the component. ws will store the connection instance, and channels will store a mapping of message channels to callback functions.
  • Subscribe and Unsubscribe Functions: subscribe and unsubscribe functions are defined. These functions allow components to interact with the communication system. Components can register callbacks for specific message channels using subscribe and remove those callbacks using unsubscribe.
  • WebSocket Initialization and Cleanup: The useEffect hook is used for initializing the connection when the component mounts and cleaning it up when the component unmounts. It sets up the connection, specifies event handlers for different WebSocket events (open, close, and message), and returns a cleanup function to close the connection.
  • WebSocket Message Handling: Inside the onmessage event handler of the WebSocket, incoming messages are parsed as JSON objects. The message type and data are extracted. Depending on the message type and associated chat channel, the appropriate callback function is invoked from the channels mapping. In this case, the channel MESSAGE_CREATE_${id} will be associated to the Chat component, described later.
    If there's no specific channel callback, a generic type callback is invoked. In this example, the generic channelMESSAGE_CREATE will be associated to the NotificationsWrapper component, described later.

App.js

Once the WebSocketProvider has been created the messages consumer components can be wrapped into it. Return to the initial App.js file to add the new component.

Chat.js (WS Consumer)

The code snippet below demonstrates the implementation of theChat component logic that utilizes the WebSocket context to receive and display real-time messages.

Here’s a breakdown of the code:

  • Import Statements: The necessary React hooks and the WebSocketContext are imported from their respective modules.
  • Initialization Statements: The id parameter is obtained from the URL using the useParams() hook. This parameter likely identifies a specific chat or conversation. All messages related to this chat have this id in their JSON specification.
    The component sets up a state variable messages using the useState() hook. This state will hold the messages for UI display.
    The subscribe and unsubscribe functions are accessed from the WebSocketContext using the useContext() hook.
  • Component Lifecycle: The useEffect() hook is employed to set up the WebSocket interaction. It runs when the component mounts and when the id, subscribe, or unsubscribe values change.
  • Subscription Callback: The component subscribes to the WebSocket channel by invoking the subscribe function with the channelName and a callback. This callback is triggered when a message is received on the channel. The callback adds the received message to the UI by updating the messages state using the setMessages function. The new message is appended to the existing array of messages.
  • Subscription Cleanup: The useEffect() hook also returns a cleanup function. This function ensures that the component unsubscribes from the WebSocket channel when it is unmounted, using the unsubscribe function.

NoticationsWrapper.js (WS Consumer)

Lastly, the NotificationsWrapper component is added. This component has a similar logic WebSocketContext as the previous one. The only major difference lies in the type of channel it subscribes to: instead of subscribing to a single channel related to a single chat identifier, this channel captures all messages that do not correspond to an existing rendered chat.

Design Improvements

The initial example provided a basic implementation of the pattern. However, the design is highly adaptable and can readily incorporate enhancements for new features.

The following report is sourced directly from the repository, outlining two additional improvements within the WSContext:

  1. Support for different types of messages: The design extension goes beyond merely generating new messages. It now includes the ability to instantly remove individual messages and even the entire chat in real-time.
  2. WebSocket synchronization with user authentication: In more intricate applications, functionalities related to stateful communication channels (just like WS), are often restricted to authenticated users. In the following illustration, a user property, signifying the currently logged-in user, is utilized to handle the establishment and termination of the WebSocket connection.

From this last example, it can be seen that to formalize channel names, a channelTypes object was created as a collector of functions that return the channel name.

The userobject present in App.jsobject is injected as a property into the WebSocketProvider. This object is present as a dependency of the useEffect. After a login and logout operation, a callback will set this object with the registered user’s properties or remove them. With this principle, the WebSocket connection or destruction is attached as a dependency of those operations.

Conclusion

This article concludes with the hope that the value of this approach has been effectively demonstrated, addressing any doubts that may have arisen. Your attention and engagement are genuinely appreciated, and I extend my sincere thanks to you for that!

Thank you for reading until the end. Please consider following the writer and this publication. Visit Stackademic to find out more about how we are democratizing free programming education around the world.

External Resources

The nyla-instant-messaging-app repository contains a comprehensive illustration of a centralized instant messaging application. Inside this repository, you’ll discover the full implementation details of the application.

In particular, the repository provides the implementation for:

  1. React.js Client
  2. Express.js REST-API Server
  3. WebSocket.js Server

--

--