DEV Community

Cover image for Really Reactive React
Jin
Jin

Posted on • Originally published at mol.hyoo.ru

Really Reactive React

ReactJS is now the most popular framework, despite many architectural blundes. Here are just a few of them:

  • The component does not track external states on which it depends; it is updated only when the local one changes. This requires careful subscriptions/unsubscriptions and timely notifications of changes.
  • The only way to change one parameter of a component is to completely re-render the external component, regenerating all the parameters for both it and neighbors. The same applies to adding/removing/moving a component.
  • When moving a component between containers, it is completely recreated. Conversely, different instances of the same component may be inappropriately reused.
  • Callbacks created on the fly lead to unnecessary rerenders, so they require careful memoization with a precise indication of the variables used inside them.
  • To update values in closures, you still have to recreate callbacks, which leads to unnecessary rerenders.
  • Hooks cannot be used in conditions, loops and other places where the execution flow is dynamic, otherwise everything will break.
  • Errors and wait indications occur outside the component. The component turns out to be not self-sufficient and fatally affects both the external component and, as a result, its neighbors.
  • The component cannot be partially updated - only a full rerender. To overcome this, they either coat themselves with memoization or unnecessarily increase the granularity of the components.
  • Lack of control of a stateful component often leads to the need to split each component into two: a controlled stateless and an uncontrolled stateful wrapper over it. Partial control is associated with difficulties and copy-paste.

Well, let's cure the patient, and at the same time show the ease of integration of the reactive library $mol_wire into a completely foreign architecture.

Let's start from afar - write a synchronous function that loads JSON via a link. To do this, write an asynchronous function and convert it to synchronous:

export const getJSON = sync( async function getJSON( uri: string ){
    const resp = await fetch(uri)
    if( Math.floor( resp.status / 100 ) === 2 ) return resp.json()
    throw new Error( `${resp.status} ${resp.statusText}` )
} )
Enter fullscreen mode Exit fullscreen mode

Now let's implement the API for GitHub, with debounce and caching. We will only support loading issue data by its number:

export class GitHub extends Object {

    // cache
    @mems static issue( value: number, reload?: "reload" ) {

        sleep(500) // debounce

        const uri = `https://api.github.com/repos/nin-jin/HabHub/issues/${value}`
        return getJSON( uri ) as {
            title: string
            html_url: string
        }

    }

}
Enter fullscreen mode Exit fullscreen mode

No matter how many times we access the data, the result will be returned from the cache, but if we still need to reload the data, we can pass an additional parameter to start the task of updating the cache. In any case, the request will not go immediately, but with a delay of half a second.

Now, finally, we move on to creating components. Contrary to the popular trend, we will not emulate objects through dirty functions with hooks, but will use class components. And in order not to repeat the same logic, let's create a base class for our components:

export abstract class Component<
    Props = { id: string },
    State = {},
    SnapShot = any
> extends React.Component<
    Partial<Props> & { id: string },
    State,
    SnapShot
> {

    // every component should have guid
    id!: string;

    // show id in debugger
    [Symbol.toStringTag] = this.props.id

    // override fields by props to configure
    constructor(props: Props & { id: string }) {
        super(props)
        Object.assign(this, props)
    }

    // composes inner components as vdom
    abstract compose(): any

    // memoized render which notify react on recalc
    @mem render() {
        log("render", "#" + this.id)
        Promise.resolve().then(() => this.forceUpdate())
        return this.compose()
    }

}
Enter fullscreen mode Exit fullscreen mode

The main idea here is that each component should be completely self-sufficient, but at the same time controllable - any of its public fields can be overridden through props. All props are optional, except for the identifier, which we require to be set externally so that it is globally unique and semantic.

It is important to note that props are not tracked by the reactive system - this allows you to pass callbacks to them and this will not cause rerenders. The idea here is to separate the initialization (by pushing props) and the actual work (by pulling through callbacks provided in the props).

Initialization occurs when the class is constructed, and dynamic work occurs when the framework calls render. ReactJS is notorious for calling it too often. Here, thanks to memoization, we take over control from the framework over when rerenders will actually occur. When any dependency on which the rendering result depends changes, the reactive system will recalculate it and notify the framework about the need for rerender, then the framework will call render and will receive a fresh VDOM. In other cases, it will receive the VDOM from the cache and do nothing further.

This scheme of work will no longer allow the use of hooks in your logic, but with $mol_wire, hooks are like a dog’s fifth leg.

It's easier to understand how it works with specific examples, so let's create a simple component - a text input field:

export class InputString extends Component<InputString> {

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

    change( event: ChangeEvent<HTMLInputElement> ) {
        this.value( event.target.value )
        this.forceUpdate() // prevent caret jumping
    }

    compose() {
        return (
            <input
                id={ this.id }
                className="inputString"
                value={ this.value() }
                onInput={ action(this).change }
            />
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Here we have declared a state in which the entered text is stored by default, and the action causes the update of this state to be called upon input. At the end of the action, we force ReactJS to instantly pick up our changes, otherwise the caret will fly away at the end of the input field. In other cases this is not necessary. Well, when passing the action to VDOM, we wrapped it in a wrapper that simply turns a synchronous method into an asynchronous one.

Now let's use this component in the number input field, in which we will raise the text of the status input field:

export class InputNumber extends Component<InputNumber> {

    // 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 ?? "" )
    }

    compose() {
        return (
            <div
                id={this.id}
                className="inputNumber"
                >

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

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

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

            </div>
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Please note that we have overridden the value property of the text input field, so now it will store its state not in itself, but in our str property, which is actually a cached delegate to the numb property. Its logic is a little convoluted so that when an invalid number is entered, we do not lose user input due to its replacement with a normalized value.

You can notice that the VDOM we generated does not depend on any reactive states, which means it will be calculated only once during the first render, and will not be updated again. But despite this, the text field will respond correctly to changes in the numb properties. and as a consequence str.

The Button components are also used here. which have overridden methods called to get the name of the button and to perform an action when clicked. But more about buttons later, but for now let’s use all our developments to implement an advanced Counter, which not only switches the number with buttons, but also downloads data from the server:

export class Counter extends Component<Counter> {

    @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;
    }

    compose() {
        return (
            <div
                id={ this.id }
                className="counter"
                >

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

                <Safe
                    id={ `${this.id}-output-safe` }
                    task={ () => (

                        <a
                            id={ `${this.id}-link` }
                            className="counter-link"
                            href={ this.link() }
                            >
                            { this.title() }
                        </a>

                    ) }
                />

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

            </div>
        )
    }

}
Enter fullscreen mode Exit fullscreen mode

As it is not difficult to notice, we have raised the state of the text input field even higher - now it operates with the number issue. Using this number, we load the data via the GitHub API and display it side by side, wrapped in a special component Safe, whose task is to handle exceptions in the code passed to it: when waiting, show the corresponding indicator, and in case of an error, show the error text. It is implemented simply - with the usual try-catch:

export abstract class Safe extends Component<Safe> {

    task() {}

    compose() {

        try {
            return this.task()
        } catch( error ) {

            if( error instanceof Promise ) return (
                <span
                    id={ `${this.id}-wait` }
                    className="safe-wait"
                    >
                    💤
                </span>
            )

            if( error instanceof Error ) return (
                <span
                    id={ `${this.id}-error` }
                    className="safe-error"
                    >
                    {error.message}
                </span>
            )

            throw error
        }

    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, let’s implement a button, but not a simple one, but a smart one that can display the status of the task being performed:

export class Button extends Component<Button> {

    title() {
        return ""
    }

    action( event?: MouseEvent<HTMLButtonElement> ) {}

    @mem click( next?: MouseEvent<HTMLButtonElement> | null ) {
        if( next ) this.forceUpdate()
        return next;
    }

    @mem status() {

        const event = this.click()
        if( !event ) return

        this.action( event )
        this.click( null )

    }

    compose() {
        return (

            <button
                id={this.id}
                className="button"
                onClick={ action(this).click }
                >

                { this.title() } {" "}

                <Safe
                    id={ `${this.id}-safe` }
                    task={ () => this.status() }
                />

            </button>

        )
    }

}
Enter fullscreen mode Exit fullscreen mode

Here, instead of immediately launching the action, we put the event in the reactive property click, on which the status property depends, which is already responsible for launching the event handler. And so that the handler is called immediately, and not in the next animation frame (which is important for some JS APIs like clipboard), forceUpdate is called. Himself status in normal situations it does not return anything, but in case of a wait or an error it shows the corresponding blocks thanks to Safe.

All the code for this example can be found in sandbox:

Logs have also been added there so you can understand what is happening. For example, this is what the primary rendering looks like:

render #counter 
render #counter-numb 
render #counter-numb-decrease 
render #counter-numb-decrease-safe 
render #counter-numb-input 
render #counter-numb-increase 
render #counter-numb-increase-safe 
render #counter-title-safe 
render #counter-reload 
render #counter-reload-safe 

fetch GitHub.issue(48) 
render #counter-title-safe 
render #counter-title-safe
Enter fullscreen mode Exit fullscreen mode

Here #counter-title-safe was rendered 3 times because first it showed 💤 on debounce, then while waiting for the data to actually load, and at the end it showed the loaded data.

When you press Reaload again, nothing unnecessary is rendered - only the wait indicator on the button changes, since the data ultimately did not change:

render #counter-reload-safe

fetch GitHub.issue(48)
render #counter-reload-safe
render #counter-reload-safe
Enter fullscreen mode Exit fullscreen mode

Well, when you quickly change the number, the text input field is updated and the heading that depends on it is displayed:

render #counter-numb-input
render #counter-title-safe

render #counter-numb-input
render #counter-title-safe

fetch GitHub.issue(4)
render #counter-title-safe
render #counter-title-safe
Enter fullscreen mode Exit fullscreen mode

In total, what problems we solved:

  • ✅ The component automatically tracks external states pointwise (not like with Redux).
  • ✅ Component parameters are updated without rerendering the parent.
  • ❌ Component movement is still controlled by ReactJS.
  • ✅ Changing the callback does not lead to rerendering.
  • ✅ Our analogue of hooks can be used anywhere in the code, even in loops and conditions.
  • ❌ Error handling is still managed by ReactJS, so it requires manual work.
  • ✅ For a partial update, you can create a component that accepts a closure.
  • ✅ stateful components are fully controlled.

You can modify this example and design it as a library like remol if you are ready to support it. Or implement similar integration for any other framework. In the meantime, we are undocking the first stage and flying even higher...

Top comments (0)