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:

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 propsbut 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 typewhich 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 CHILDRENwhich 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 clientComponentsMapto 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!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *