Reactive React with RxJS
tl;dr;
Using WebSockets is a great way to build highly interactive real-time apps. Using raw WebSockets in React can be cumbersome. RxJS is a great way to manage streams of data in JavaScript and makes reactive React a delight.
Server and React Code for this example can be found on GitHub here
For a simplified version, see the code reference at the end of this article.
Introduction - Why do this?
React is a great library for building user interfaces, but it's not always a great library for building truly reactive applications. RxJS is a great library for building reactive applications, but it doesn't help build user interfaces. This article will show you how to combine the two to build a truly reactive application with React.
Aside: Angular
I'm sorry to my Angular friends who have been using RxJS for years. I'm not taking anything away from Angular. I'm only trying to show you how to use RxJS with React.
What is Reactive Programming?
Reactive programming is a paradigm that allows us to set up our code in a way that we accept incoming messages and the front end reacts to the changes from the message. These changes could come from server events, other clients, etc. Reactive programming is framework/library agnostic.
This is slightly different than React.js traditionally thinks of the world. We make state or prop changes and the virtual dom reacts to those changes the user made.
If you would like an explanation on WebSockets first, check them out in this quick explanation here.
RxJS With React
If you would like an explanation on why we'd use RxJS instead of raw WebSockets, check out this article on WebSockets with RxJS.
Now that we've gotten the housekeeping of why RxJS and WebSockets out of the way, let's get into how to use RxJS with React. I'll be using TypeScript for this example.
Setup
With a React project (create-react-app, vite, Next.js, etc.) we can install RxJS with:
$ npm install rxjs
or
$ yarn add rxjs
Once we have RxJS installed, we can set up the websocket connection to use in our React components.
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
const wsURL = 'ws://localhost:8080'
const wsConfig: WebSocketSubjectConfig<Message> = { url: wsURL }
const ws = webSocket<Message>(wsConfig)
export function App() {
return (
<div className="App">
<h1>React with RxJS</h1>
</div>
)
}
What is that <Message>?
We have to define the shape of the messages we are sending and receiving. We can do this with an interface in TypeScript.
interface Message {
operation?: '+' | '-'
count?: number
}
In this case we can send a message with an operation of either + or - and
receive the count from the server with a number.
Listening for messages to setState
We need to be able to get the current state of the WebSocket. This highly depends on the application but for our this example, we have a simple counter.
const [count, setCount] = useState(0)
ws.pipe(
tap((data) => {
setCount(data?.count ?? 0)
}),
).subscribe()
We can use the pipe function to chain together multiple operators before we subscribe to the WebSocket.
In this case, we use the tap operator to set state when we receive a message.
So if we set up a <p>Count {count}</p> we should automagically see the current count from the server, right?.
YES!! But only because that is the behavior we have set up on the server. That when a client connects, we send the current count.
We can also send messages to the server. Let's add a button to increment and decrement the count
<div className="App">
<p>Count {count}</p>
<button onClick={() => ws.next({ operation: '+' })}>+</button>
<button onClick={() => ws.next({ operation: '-' })}>-</button>
</div>
In this case, we don't have to set our state after we send the message. The server will send us the response and we can set our state then.
Optimistic UI
React superpower alert
Optimistic UIs are a way that we can make even the most complex applications in a slow network feel fast.
However, we can also set our state before we send the message. This is called an optimistic UI.
This is a common pattern in Mobile or Game development. We can set our state and then send the message
assuming that all will go according to plan. However, if the server responds with an error (or a non-incremented counter)
in our case. The setState will be overwritten with the correct value from the server
function increment() {
setCount(count + 1)
ws.next({ operation: '+' })
}
function decrement() {
setCount(count - 1)
ws.next({ operation: '-' })
}
return (
<div className="App">
<p>Count {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
Conclusion
This has been a simple example of how to write Reactive React. In my next article, I'll be putting these into more practical use cases. I'll be building a Rock Paper Scissors Lizard Spock game with RxJS on the front end and the Serverless framework on the back end for a websocket API in AWS.
See the code here. See the live demo here.
Resources - Full code
Backend WebSocket Server
// Importing the required modules
const WebSocketServer = require('ws')
// Creating a new websocket server
const wss = new WebSocketServer.Server({ port: 8080 })
let globalCount = 0
// Creating connection using websocket
wss.on('connection', (ws) => {
console.log('new client connected')
// sending & receiving message
ws.on('message', (data) => {
console.log(`Client has sent us: ${data}`)
if (JSON.parse(data)?.operation === '+') {
globalCount++
}
if (JSON.parse(data)?.operation === '-') {
globalCount--
}
// send message to all connected clients
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocketServer.OPEN) {
client.send(JSON.stringify({ count: globalCount }))
}
})
})
// returned when a client connects
return ws.send(JSON.stringify({ count: globalCount }))
})
console.log('The WebSocket server is running on port 8080')
Frontend React App
import { useState } from 'react'
import './App.css'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { tap } from 'rxjs/operators'
interface Message {
operation?: '+' | '-'
count?: number
}
const wsURL = 'ws://localhost:8080'
const wsConfig: WebSocketSubjectConfig<Message> = {
url: wsURL,
openObserver: {
next: () => {
console.log('WS connection opened')
},
},
closeObserver: {
next: (event) => {
// important to do if we're unmounting the component
if (event.wasClean) ws.complete()
},
},
}
const ws = webSocket<Message>(wsConfig)
function App() {
const [count, setCount] = useState(0)
ws.pipe(
tap((data) => {
setCount(data?.count ?? 0)
}),
).subscribe()
function increment() {
setCount(count + 1)
ws.next({ operation: '+' })
}
function decrement() {
setCount(count - 1)
ws.next({ operation: '-' })
}
return (
<div className="App">
<p>Count {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
export default App