ninjin

Jin

Posted on March 3, 2024

Reactive JSX

It’s not hard to see that by taking away state control from ReactJS, we’re actually dethroning it from the framework’s pedestal to the level of the DOM rendering library that it originally was. But this turns out to be a very heavy rendering library, doing too much unnecessary work and wasting a lot of memory.

Let's take bare, strongly typed JSX and make it reactive using $mol_wire, creating a complete ReactJS replacement, but without the VirtualDOM, but with spot updates to the real DOM and other nice goodies.

To do this, we will first take $mol_jsx, which is the same as E4X creates real DOM nodes, not virtual ones:

const title = <h1 class="title" dataset={{ slug: 'hello' }}>{ this.title() }</h1>
const text = title.innerText // Hello, World!
const html = title.outerHTML // <h1 class="title" data-slug="hello">Hello, World!</h1>
Enter fullscreen mode Exit fullscreen mode

Wow! We no longer need ref to get the DOM-node from JSX, because we immediately get a DOM tree from it.

If you execute JSX for a reason, but in the context of a document, then instead of creating new elements, existing ones will be used, based on their identifiers:

<body>
    <h1 id="title">...</h1>
</body>
Enter fullscreen mode Exit fullscreen mode
$mol_jsx_attach( document, ()=> (
    <h1 id="title" class="header">Wow!</h1>
) )
Enter fullscreen mode Exit fullscreen mode
<body>
    <h1 id="title" class="header">Wow!</h1>
</body>
Enter fullscreen mode Exit fullscreen mode

Wow! We also got hydration, but without the division into primary and secondary rendering. We simply render and existing elements are reused if they exist.

Wow! We also got correct moves components, instead of recreating them in a new place. And no longer within the framework of one parent, but within the entire document:

<body>
    <article id="todo">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
    <article id="done"></article>
</body>
Enter fullscreen mode Exit fullscreen mode
$mol_jsx_attach( document, ()=> (
    <article id="done">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
) )
Enter fullscreen mode Exit fullscreen mode
<body>
    <article id="todo"></article>
    <article id="done">
        <h1 id="task/1">Complete article about $mol_wire</h1>
    <article>
</body>
Enter fullscreen mode Exit fullscreen mode

Pay attention to the use of natural HTML attributes id and class instead of ephemeral key and className.

Of course, you can use both templates (stateless functions) and components (stateful classes) as tags. The first ones are simply called with the correct context, which means they unconditionally render their content. And the latter create an instance of the object, delegate rendering control to it, and save a reference to it in the resulting DOM node in order to use it again during the next rendering. In runtime it looks something like this:

Here we see two components that returned the same DOM element as a result of rendering. Getting instances of components from a DOM element is not difficult:

const input = InputString.of( element )
Enter fullscreen mode Exit fullscreen mode

So, let's create the simplest component - a text input field:

export class InputString extends View {

    // statefull!
    @mem value( next = "" ) {
        return next
    }

    // event handler
    change( event: InputEvent ) {
        this.value( ( event.target as HTMLInputElement ).value )
    }

    // apply state to DOM
    render() {
        return (
            <input
                value={ this.value() }
                oninput={ action(this).change }
            />
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Almost the same code as with ReactJS, but:

  • Since reconciliation during rendering occurs with the real DOM, and not with the previous version of the virtual one, there is no need for the crutch of immediately updating the virtual DOM after processing the event, so that the carriage does not fly to the end when entering.
  • Events come native, not synthetic, which eliminates a lot of surprises.
  • Classes for styling are generated automatically based on component identifiers and names.
  • There is no need to manually collect element identifiers - semantic identifiers are also generated automatically.
  • For the root element, the identifier does not need to be specified at all - it is set equal to the component identifier.
  • If there is a conflict between identifiers, an exception is thrown, which guarantees their global uniqueness.

To illustrate the last points, let's look at a more complex component - a number input field:

export class InputNumber extends View {

    // self state
    @mem numb( next = 0 ) {
        return next
    }

    dec() {
        this.numb( this.numb() - 1 )
    }

    inc() {
        this.numb( this.numb() + 1 )
    }

    // lifted string state as delegate to number state!
    @mem str(str?: string) {

        const next = str?.valueOf && Number( str )
        if( Object.is( next, NaN ) ) return str ?? ""

        const res = this.numb(next)
        if( next === res ) return str ?? String( res ?? "" )

        return String( res ?? "" )
    }

    render() {
        return (
            <div>

                <Button
                    id="decrease"
                    action={ () => this.dec() }
                    title={ () => "" }
                />

                <InputString
                    id="input"
                    value={ next => this.str( next ) } // hack to lift state up
                />

                <Button
                    id="increase"
                    action={ () => this.inc() }
                    title={ () => "" }
                />

            </div>
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Using the generated classes, it is easy to attach styles to any elements:

/** bem-block */
.InputNumber {
    border-radius: 0.25rem;
    box-shadow: 0 0 0 1px gray;
    display: flex;
    overflow: hidden;
}

/** bem-element */
.InputNumber_input {
    flex: 1 0 auto;
}

/** bem-element of bem-element */
.Counter_numb_input {
    color: red;
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, it is not possible to implement a full-fledged CSS-in-TS in JSX, but even just auto-generation of classes already significantly simplifies styling.

For all this to work, you only need to implement a base class for reactive JSX components:

/** Reactive JSX component */
abstract class View extends $mol_object2 {

    /** Returns component instance for DOM node. */
    static of< This extends typeof $mol_jsx_view >( this: This, node: Element ) {
        return node[ this as any ] as InstanceType< This >
    }

    // Allow overriding of all fields via attributes
    attributes!: Partial< Pick< this, Exclude< keyof this, 'valueOf' > > >

    /** Document to reuse DOM elements by ID */
    ownerDocument!: typeof $mol_jsx_document

    /** Autogenerated class names */
    className = ''

    /** Children to render inside */
    @field get childNodes() {
        return [] as Array< Node | string >
    }

    /** Memoized render in right context */
    @mem valueOf() {

        const prefix = $mol_jsx_prefix
        const booked = $mol_jsx_booked
        const crumbs = $mol_jsx_crumbs
        const document = $mol_jsx_document

        try {

            $mol_jsx_prefix = this[ Symbol.toStringTag ]
            $mol_jsx_booked = new Set
            $mol_jsx_crumbs = this.className
            $mol_jsx_document = this.ownerDocument

            return this.render()

        } finally {

            $mol_jsx_prefix = prefix
            $mol_jsx_booked = booked
            $mol_jsx_crumbs = crumbs
            $mol_jsx_document = document

        }

    }

    /** Returns actual DOM tree */
    abstract render(): HTMLElement

}
Enter fullscreen mode Exit fullscreen mode

Finally, having finished with the preparations, let’s write our application:

export class Counter extends View {

    @mem numb( value = 48 ) {
        return value
    }

    issue( reload?: "reload" ) {
        return GitHub.issue( this.numb(), reload )
    }

    title() {
        return this.issue().title
    }

    link() {
        return this.issue().html_url
    }

    render() {
        return (
            <div>

                <InputNumber
                    id="numb"
                    numb={ next => this.numb(next) } // hack to lift state up
                />

                <Safe
                    id="titleSafe"
                    task={ ()=> (
                        <a id="title" href={ this.link() }>
                            { this.title() }
                        </a>
                    ) }
                />

                <Button
                    id="reload"
                    action={ ()=> this.issue("reload") }
                    title={ ()=> "Reload" }
                />

            </div>
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

All code for this example can be found in sandbox. So in 1 evening we implemented our ReactJS based on $mol, adding a bunch of unique features, but reducing the volume of the bundle by 5 times. In terms of speed, we are neck and neck with the original:

What about the inverse task - writing an analogue of the $mol framework in ReactJS? You will need at least 3 million dollars, a team of a dozen people and several years of waiting. But we will not wait, but will undock this stage too..

💖 💪 🙅 🚩
ninjin
Jin

Posted on March 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Reactive JSX
webdev Reactive JSX

March 3, 2024