Reactive JSX
Jin
Posted on March 3, 2024
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>
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>
$mol_jsx_attach( document, ()=> (
<h1 id="title" class="header">Wow!</h1>
) )
<body>
<h1 id="title" class="header">Wow!</h1>
</body>
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>
$mol_jsx_attach( document, ()=> (
<article id="done">
<h1 id="task/1">Complete article about $mol_wire</h1>
<article>
) )
<body>
<article id="todo"></article>
<article id="done">
<h1 id="task/1">Complete article about $mol_wire</h1>
<article>
</body>
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 )
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 }
/>
)
}
}
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>
)
}
}
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;
}
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
}
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>
)
}
}
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..
Posted on March 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.