Passing React components over WebSocket
A year ago, the React team introduced server components (not to be confused with SSR). In short, the point is that the component is created on the server, serialized into tricky json, sent to the client via http, and the client deserializes and renders it as a regular react component (this is the most noticeable difference from SSR, which is sent to the client by a ready-made html code). In general, the thing is cool, but it seems to me that it did not receive much attention from the community, maybe partly because of the raw state (that’s why this is a demo), or maybe because of the complexity of implementation and implementation into the project (IMHO)
But be that as it may, I got interested and thought, if you can transfer over HTTP, then you can also use WebSocket. Indeed, why not, and it will work much faster. Attempt to rewrite their demo I was defeated on web sockets, there is a lot of strange and incomprehensible code for me, and it was not my desire to delve deeply into the abundance of dependencies that are required for the whole thing to work.
So I started writing my personal simplified version of the server components that could be transmitted in real time over a web socket connection. Why not.
By the way, I wrote it 10 months ago and somehow forgot about it. But the other day I stumbled upon a repository and thought it was cool and decided to write an article about it.
Main idea
First of all, the client establishes a web socket connection to the server. Then, by a kind of magic import, it receives a serialized component, which is actually stored and created on the server, deserializes it, and can work with it as with a regular React component, including rendering it anywhere, transferring serializable props to it, and children
… Since the component exists only on the server, every time the props change, it must be re-requested and rendered again.
Serializing Components
The first task to be done is serializing the components. It happens exclusively on the server, from which the server components are very limited, in fact, they must include only JSX and cannot use state, in fact, like any other hooks.
The task of serializing a component comes down to serializing directly to JSX. I do not write class components from the word at all, so I have not even considered the process of serializing them, but I do not think that it can be very different. But still I will say that all the following examples will be valid only for functional components.
First, let’s see how the components look in general if you output them to the console (I removed unnecessary properties, for compactness)
Empty html element
console.log(<div />)
{
$$typeof: Symbol(react.element),
props: {},
type: "div"
}
html element with a string inside
console.log(<div>habr<div>)
{
$$typeof: Symbol(react.element),
props: { children: 'habr' },
type: "div"
}
html element with data array inside
console.log(<div>{['habr', 42]}</div>)
{
$$typeof: Symbol(react.element),
props: {
children: ['habra', 42]
},
type: "div"
}
html element with another html element inside
console.log(<div><div /></div>)
{
$$typeof: Symbol(react.element)
props: {
children: {
$$typeof: Symbol(react.element),
props: {},
type: "div"
}
},
type: "div"
}
Functional component
const Component = () => <div />
console.log(<Component />)
{
$$typeof: Symbol(react.element),
props: {},
type: () => react_1.default.createElement("div", null)
}
Functional component wrapped in React.memo
const Component = React.memo(() => <div />)
console.log(<Component />)
{
$$typeof: Symbol(react.element),
props: {},
type: {
$$typeof: Symbol(react.memo),
compare: null,
type: () => react_1.default.createElement("div", null)
}
}
Fragment
console.log(<></>)
{
$$typeof: Symbol(react.element),
props: {},
type: Symbol(react.fragment)
}
So, from this we can conclude that all that is needed for serialization is type
and props
… But here’s what to consider:
children
- string / number when the child is string / number
- array of strings / numbers when there are multiple children of strings / numbers
- object when the child is a component / html element
- an array of objects when there are multiple child components / html elements
type
- string for html elements
- function for components
- object for components wrapped in
memo
Symbol
for a fragment
Based on this data, I got the following serialization function (I removed some additional checks to focus only on the main thing, the full version can be viewed in the github):
const decomposeFunctionElement = (Component, props) => {
// call functional component as function :3
const decomposed = Component(props)
return serializeReactElement(decomposed)
}
const serializeChildren = (children) => {
if (children === null || typeof children === 'undefined') {
return null
}
if (!Array.isArray(children)) {
return serializeReactElement(children)
}
return children.map(serializeReactElement)
}
const serializeReactElement = (element) => {
// строки и числа оставляем как есть
if (typeof element === 'string' || typeof element === 'number') {
return element
}
const {
type,
props: { children, ...props } = {},
} = element
// Memo
if (typeof type === 'object') {
return decomposeFunctionElement(type.type, element.props)
}
// Function
if (typeof type === 'function') {
return decomposeFunctionElement(type, element.props)
}
const serializedChildren = serializeChildren(children)
if (serializedChildren) {
props.children = serializedChildren
}
// HTML tag
return { type, props }
}
The main function is serializeReactElement
… In fact, the principle of its operation is that I leave only properties from the component type
and props
but for the case when type
it is a function or an object, then it is slightly different. The most interesting thing happens in decomposeFunctionElement
, where I call the react component as a regular function, thereby “parsing” it and getting pure JXS, which I serialize again, and so on recursively. And with memo
almost everything is the same, he has another type
which is already a function and is a directly wrapped component
At the output, I get the following result:
const Component = ({ title, children }) => (
<div>
<span>{title}</span>
{children}
</div>
)
const jsx = (
<Component title="Title">
<p>Content</p>
</Component>
)
const serialized = serializeReactElement(jsx)
console.log(serialized)
{
type: 'div',
props: {
children: [
{
type: 'span',
props: {
children: 'Title',
},
},
{
type: 'p',
props: {
children: 'Content',
},
},
],
},
}
Server
Now that I can serialize components, I can write a server. Its implementation turned out to be as simple and compact as possible.
// wss - любой веб сокет сервер
wss.on('connection', socket => {
socket.on('message', message => {
const { path, props } = JSON.parse(message)
const Component = require(resolve(__dirname, 'example', path)).default
const element = React.createElement(Component, props, Tags.CHILDREN)
socket.send(JSON.stringify({
path,
element: serializeReactElement(element)
}))
})
})
The client requests a component from the server, passing it path
– the path to it and props
– the props with which to create the component. I receive the component and through the built-in function in react createElement
create it.
children in server components
But there is an interesting point. The component usually has more children
, in fact it is a props, but in createElement
it is allocated as a separate argument. And since children
are available only on the client, then when creating a component I specify a special tag instead Tags.CHILDREN
, thus, as if telling the client “here in this place, when deserializing a component, you must insert your children
“
What is this tag? And this is the most common object
const CHILDREN = {
type: 'CHILDREN'
}
Now, in addition to all the other types, I have an additional CHILDREN
which I created and needs to be considered when serializing.
With a tag like this, the serialization result will look like this:
const Component = ({ children }) => (
<div>{children}</div>
)
const cmp = React.createElement(Component, {}, Tags.CHILDREN)
const serialized = serializeReactElement(cmp)
{
type: 'div',
props: {
children: {
type: 'CHILDREN',
}
},
}
Client components in server rooms
Likewise, you need to support the ability to use client components in server rooms. Again, the client components are accessible only from the client code, so for the server components, in the places where the client will need to be inserted, I use another special тег
I tell the client “right here, when deserializing a component, you will need to insert a client component with such and such a name.”
This is what this tag looks like
const CLIENT_COMPONENT = (path, props) => {
return {
type: 'CLIENT_COMPONENT',
props,
name,
}
}
const importClientComponent = (name) => (props) => {
return CLIENT_COMPONENT(name, props)
}
Now in the server components, if you need to insert the client, instead of the usual import/require
you need to use the function importClientComponent
, which returns a new component, and it, in turn, dynamically creates a tag with the necessary information for the client.
Example
const ClientComponent = importClientComponent('Counter')
const ServerCompnent = () => {
return (
<div>
<ClientComponent initValue={0} step={2}>
</div>
)
}
And when serializing, you get the following result:
{
type: 'div',
props: {
children: {
type: 'CLIENT_COMPONENT',
props: {
initValue: 0,
step: 2
},
name: 'Counter'
}
},
}
Customer
I figured out the server, it remains to write the client and the trick is in the bag. I’ll start with deserialization, it’s almost not complicated
const clientComponentsMap = {
Counter: require('./Counter.js').default
}
const deserializeChildren = (children, clientChildren) => {
if (!children || (Array.isArray(children) && !children.length)) {
return null
}
if (!Array.isArray(children)) {
return deserializeReactElement(children, clientChildren)
}
return children.map(child => deserializeReactElement(child, clientChildren))
}
const deserializeReactElement = (element, clientChildren = null) => {
if (typeof element === 'string' || typeof element === 'number') {
return element
}
const { type, name, props = {} } = element
if (type === 'CHILDREN') {
return clientChildren
}
if (type === 'CLIENT_COMPONENT' && name) {
const Component = clientComponentsMap[name]
return createElement(Component, props, deserializeChildren(props.children, clientChildren))
}
return createElement(type, props, deserializeChildren(props.children, clientChildren))
}
I take into account the tags that the server leaves us: for CHILDREN
– I return directly children
, for CLIENT_COMPONENT
– I get the client component by name and create it, and for everything else I just create the component. I used parcel
as a collector and, as I understand it, does not support dynamic imports, so the client components had to be imported and saved to the object in advance clientComponentsMap
to be able to access them by their name.
Importing Server Components to Client Components
Now the most important thing is how to render the server component in the client. First, you need to create a web socket connection and wrap the entire application in WebSocketProvider
and WaitWebSocketOpen
…
const ws = new WebSocket('ws://localhost:3000')
// ...
const ServerComponent = importServerComponent('./ServerComponent.js')
<WebSocketProvider value={ws}>
<WaitWebSocketOpen fallback={<h1>Loading...</h1>}>
<ServerComponent value="some_value">
Some child
</ServerComponent>
</WaitWebSocketOpen>
</WebSocketProvider>
The first is just a context to be able to access the web socket connection from any component, and the second is waiting for the connection to open and the loader to display. Nothing unusual so far. And the magic import itself
server components is done using the function importServerComponent
… This is a higher-order function and here is its implementation:
const importServerComponent = (path) => ({ children: clientChildren, ...props } = {}) => {
const [element, setElement] = useState(null)
const ws = useWebSocket()
useEffect(() => {
ws.onComponent(path, (data) => setElement(data.element))
}, [])
useEffect(() => {
ws.send(JSON.stringify({ path, props }))
}, [JSON.stringify(props)])
if (!element) {
return null
}
return element.type // type is undefined for Fragment and maybe some another
? deserializeReactElement(element, clientChildren)
: deserializeChildren(element.props.children, clientChildren)
}
It takes the path to the component relative to the server and returns a component that sends a request to receive the component from the server, and when the component arrives, it deserializes and renders it.
The only thing left is the use of a strange function ws.onComponent
… It is optional and can be implemented in any way you like. But its essence is that I subscribe to onmessage
once. On the onComponent
I register callbacks to components that are sent to me by the server, and at the event onmessage
I call the required callback depending on path
ws.componentsMap = new Map()
ws.onComponent = (path, callback) => {
ws.componentsMap.set(path, callback)
}
ws.onmessage = message => {
const data = JSON.parse(message.data)
const callback = ws.componentsMap.get(data.path)
if (callback) callback(data)
}
And that’s it, this is a very compact implementation of server components. Of course, it is far from ideal, not super functional and, perhaps, I did not think about some points, but in general it was interesting and it turned out, it seems to me, too, not bad
The idea is that the component is rendered on the server, which means it has access to the entire server layer. This means that you can make queries to the database directly in it, I did not include this in the article, but in the github you can see how easy it is to do server components asynchronous
Another plus is that heavy libraries that are usually required on the client can be ported to the server, so they will not be included in the bundle. A striking example (others I can not think of) can be marked
and sanitize-html
… For example, the bundle of my demo of a visual markdown editor without using server components weighs about 380 Кб
, and with – 130 Кб
And here is a link to the full version of the code: react-websocket-components
Initially, I wanted to make a library out of this and load it into npm
, but in the end I did not do it, it seemed that it was unlikely that something useful would turn out
And that’s all, thanks for your attention, I hope the article was interesting to someone, and maybe even useful!