Introduction
This tutorial will show you how to implement the WebSocket protocol in a React application and add WebSocket data to the Redux Store. We’ll concentrate on the few key pieces of code that enable this functionality, so this tutorial won’t go into the specifics of setting up the React application and the Redux store since nothing special is going on in those places.
As with 99.9% of other WebSocket tutorials, we will be using a basic chat application as a sample project.
If you’re here to just quickly get an example of how to integrate WebSocket functionality with Redux and Redux-Saga, go ahead and skip to Step 4.
What is WebSocket?
WebSocket is a network protocol, like HTTP, for sending data over a TCP/IP network. What makes it compelling is that, unlike HTTP, it provides full duplex support between the server and client, allowing the server to push data to the client, and the client to push data to the server in real time.
Duplex communication between the server and client has actually been possible all along using plain old HTTP and some creative hacks.
Perhaps the most popular hack is called long polling. In long polling, support for data pushes from the server to the client is accomplished by the client sending a normal HTTP data request to the server, and the server not responding until it’s ready to push data. Once the client receives the pushed data, the client immediately sends another request and so on. While it’s an effective strategy for creating real time duplex communication using plain old HTTP, it takes up a large amount of server resources by drastically increasing memory and thread requirements.
Another hack is called short polling. With this approach, the client application is set up to automatically send HTTP data requests to the server at set periods. While fairly easy to implement, it has two main drawbacks. Data is not updated instantaneously, and as you can imagine, it causes a huge amount of unnecessary server traffic, even when the server has no new data to push.
The WebSocket JavaScript API is supported by all modern browsers and has plenty of use cases, so read on.
Requirements
- Node installed.
- Familiarity with React, Redux, Redux-Saga, and TypeScript.
Step 1: Clone the React Application and Node Server
- Clone the front end application here.
- Since WebSocket relies on a WebSocket supported back end, we need to cover that too. Just clone this Node server.
Step 2: Check that the Server Works
I know it’s kind of cheating to fire everything up and look at the finished product before we’ve even done anything. But hey, if you’re spending valuable time on this tutorial, it’s nice to know the code works right from the start, right?
- We’ll begin by opening a terminal in the context of the server application and downloading its dependencies. Type the following command:
npm install
- Next, start the server. Type this command in the terminal:
npm run dev
- You should see the following output in the terminal, which indicates that the server is running. Keep this terminal open for the duration of this tutorial.
WebSocket listening
Server listening on port 8080
Step 3: Check that the React Application Works
This step is essentially a repeat of the previous step, except now we’re installing and starting up the React client application.
- Open a new terminal in the context of the React application (again, keep the previous terminal open and untouched). Type the following command to download the application’s dependencies.
npm install
- Next, start the React application. Type this command in the terminal:
npm start
- The React application should automatically open in a browser tab. Depending on your computer, it may take a minute for the application to build. If that does not happen, start your browser and go to the following URL:
http://localhost:3000/
Open another browser window or browser tab and go to the same URL. Now lets chat. Anything you type in one chat pane, appears in both chat panes. Pretty neat! You’re talking to yourself. If you have Redux DevTools installed in your browser, you’ll be able to see that each chat message is saved in the Redux store.
This is a small taste of the power of WebSocket. Each connected client can push messages to the server, and the server can push messages to each client, all in real time.
Step 4: The Sagas
WebSocket functionality is handled entirely by a few sagas. The reducers and React components don’t require any special treatment , and no additional dependencies are required to handle WebSocket.
The secret sauce for adding WebSocket to our application is the saga eventChannel() method. It creates a saga channel which can programmatically emit action objects. In our case, we’re hooking the channel up to WebSocket events, so that each time a WebSocket event is emitted, the channel emits an action object.
Open the saga module: src/store/chat/sagas.ts. First let’s take a look at the event channel.
export function webSocketListener(serviceWebSocket: WebSocket) { return eventChannel((emitter) => { serviceWebSocket.onmessage = ({ data }: MessageEvent) => emitter(data); serviceWebSocket.onclose = () => emitter(END); serviceWebSocket.onerror = () => emitter(END); return () => serviceWebSocket.close(); }); }
- The eventChannel() method takes a single parameter, a function, which is supplied with an emitter() function.
- Each time the emitter function is invoked, the channel emits an action object. The emitter in our function emits an action object each time WebSocket emits an onmessage, onclose, or onerror event.
- The return for the event channel function is a method. The saga middleware invokes the method when a saga unsubscribes from the event channel. In this case, the returned method closes the WebSocket connection.
Now let’s take a look at the saga that subscribes to the channel.
export function* webSocketSaga(): Generator { try { // start websocket and channel const serviceWebSocket = new WebSocket('ws://localhost:8080/chat'); const socket = yield call(webSocketListener, serviceWebSocket); yield put(putMessage({ status: 'connected' })); yield fork(sendMessageSaga, serviceWebSocket); // subscribe to channel while (true) { const payload = yield take(socket as ActionPattern); yield put(putMessage({ list: [JSON.parse(payload as string)] })); } } finally { // channel diconnected yield put(putMessage({ list: [{ user: 'Client', text: 'Disconnected from server.' }], status: 'disconnected' })); } }
- First it instantiates the WebSocket connection and starts the event channel.
- Next it subscribes to the event channel. Note that the saga takes the channel as a standard action.
- One bit of funny business is the fork() method that starts the sendMessageSaga. We start it here rather than the root saga since we’re trying to keep dependency injection alive and well, so the second saga’s start-up occurs here where we have the WebSocket instance available.
- Each time this saga takes an action object, it puts it in a reducer. From there, application functionality is completely standard; the reducer puts the data in the store, and React components get and set the data using standard Redux useSelector() and useDispatch() methods.
- Per best practice, a try/finally statement is used to handle disconnects. The finally block lets the user know that the service was stopped.
Step 5: Testing the Sagas
It seems that everyone has their own way of testing sagas. I’ve always used saga-test-plan. It’s a fairly easy-to-use test suite that provides assertions for unit testing Saga effects. And as a bonus, it mocks HTTP responses.
However, we’re not testing HTTP, and saga-test-plan has exactly zero support for testing Saga event channels. What to do. If you look hard, you’ll find a few fairly complex integration tests devised by users. Let’s sidestep that noise and use jest-websocket-mock. This plugin mocks a WebSocket server which allows you to create fairly comprehensive integration tests.
Let’s take a look at the saga tests: src/store/chat/sagas.test.ts
import { mockServer } from '../../setupTests'; import { store } from '..'; import * as sagas from './sagas'; describe('WebSocket Sagas', () => { it('gets messages', async () => { mockServer.send({ user: 'Server', text: 'test' }); expect(store.getState().messages.list).toEqual([{ user: 'Server', text: 'test' }]); }) it('sends messages', async () => { store.dispatch(sagas.sendMessage({ user: 'Client', text: 'test' })); await expect(mockServer).toReceiveMessage({ user: 'Client', text: 'test' }); }) it('on connection, status is connected', async () => { expect(store.getState().messages.status).toEqual('connected'); }) it('on error, status is disconnected', async () => { mockServer.error(); await mockServer.closed; expect(store.getState().messages.status).toBe('disconnected'); }); });
The tests use the assertions provided by jest-websocket-mock, as well as standard React, Redux, and Jest testing syntax.
One huge gotcha is that I was forced to start the mock server in setupTests.ts rather than using a beforeEach() method. This severely limits testing ability since, A: I cannot reset the mock server before each test, and B: the tests rely on each other’s results, big no-nos. jest-websocket-mock implements mock-socket under the hood, and I’ve found that that package often requires a bit of “coaxing” in order to get it to work properly for any given project. However, it’s still worth the hassle since the only other option is to create your own mock server. If anyone has found a better way to do this, let me know!
Conclusion
Since the WebSocket API is supported by all browsers, it’s fairly easy to implement in a client application. Adding it to a Redux-Saga project is only slightly more challenging since a saga event channel must be bound to the WebSocket instance.
Socket.io is a commonly used package for implementing the WebSocket protocol. Its API is similar to the JavaScript WebSocket API. However, I did not use it in this tutorial since it is in fact NOT a WebSocket implementation. It uses a combination of HTTP long polling (as discussed previously) and WebSocket. Plus it’s fairly restrictive in that it requires both the client and server to use the socket.io package.
If you are using socket.io, this tutorial is still applicable, however, testing would require the use of the mock-socket package instead of the jest-websocket-mock package.