Home

C.V.

Bespoke Reactive UI Framework

Motivation

Maintaining and upgrading legacy systems remains a significant challenge. Sometimes, though, continuing to use an aging codebase will yield ever-problematic results in terms of bugs and overall stability, to the point where a large refactor is worth the opportunity and labour cost.

My client's site was built with a PHP backend, and WordPress/jQuery frontend stack. JavaScript was loaded by forking an open-source WordPress theme and modifying it to inject an entry point to the jQuery frontend renderer. It injected HTML script tags which loaded jQuery scripts.

My job was to enhance and refresh one of the sites "trackers" - a big interactive table with filters, search and sort functions displaying information about sporting events. Additionally, I was given the opportunity to refactor portions of the code and to introduce new abstractions to reduce code duplication, reduce the use of globals, improve code comprehensibility, fix bugs and overall tidy it up.

Each tracker was just a script with a bunch of functions to render different parts of the UI directly into the DOM using jQuery. I saw this causing problems sometimes, because there was no protocol for who can write to the DOM at a given time. Because all interactivity was implemented in global event listeners, each action was isolated. It was not possible for state to be passed around safely either. Data was written to globals and retrieved from inside the event listeners, by querying the window using an attribute provided inside the element markup - this is also one way rendering functions were invoked.

Solution

The first thing I did was try to abstract the process of setting up the right view according to the URL. A single-page application router.

I developed a JavaScript framework that functioned within the existing jQuery ecosystem. This framework facilitated creating "views" - segments of the UI that could be dynamically loaded and rendered on the page. The heart of this solution was the ViewRouter and ViewRoute classes.

Technical Overview

  • ViewRouter: Orchestrated rendering of views and managed application states (like loading, interactive, etc.).
  • ViewRoute: Encapsulated the rendering logic and state of individual UI views.

For each system (a set of trackers and other UI views), the index.js file is responsible for creating a ViewRouter and registering it's views and the corresponding URL patterns. These patterns also act as identifiers or keys, so you can ask the ViewRouter to render the view by passing it's URL pattern.

Example

jQuery(async () => {

    if (!jQuery('#app').hasClass('ep')) return;

    const router = new ViewRouter(
        {

            'tracker':           new ViewRoute(EpTracker),
            'my-selections':     new ViewRoute(MySelections),
            'my-settings':       new ViewRoute(MySettings),
            'hidden-selections': new ViewRoute(HiddenSelections),
            
            // If the user is not authenticated as an admin,
            // the server will send these functions as stubs.
            // So no sensitive controls can be exposed.
            //
            // See ui/admin/stubs.js
            //
            'selection-names': new ViewRoute(AdminEpSelectionNames),
            'ep-competitions': new ViewRoute(AdminEpCompetitions),
            'offer-types':     new ViewRoute(AdminEpOfferTypes),
        },
        'tracker', // default view
        ...

When constructing a ViewRoute, we pass in a rendering function for that route. The constructor takes care of the rest.Next, let's look at how these rendering functions are implemented.

As I hinted at earlier, the original code used jQuery to select and directly manipulate DOM objects. In ViewRouter views, we use jQuery to build a DOM tree in memory, and then the router "commits" this to the page. Afterwards, it calls a hydration callback for any code you need to run after the nodes are actually on the page; e.g. running code which uses the normal JavaScript document.get... APIs.

The stubs mentioned in the comment are a good example of the simplest view renderer its possible to implement with this framework. Take the stub renderer for the offer types view:

async function AdminEpOfferTypes() {
    return {
        root: jQuery('<div>'),
        hydrate: () => {},
    }
}

Each view returns an object containing a jQuery as a sort of "virtual DOM" which we return back to the rendering function so that it can manage when its called, and crucially run the hydrate function upon its completion.

It was a primary goal of mine to encapsulate all HTML and event listeners for a specific view entirely inside their rendering functions. In doing so, we eliminate unpredictable side-effects. The router needs to be able to predictably render each view onto the page, and it clears the target container completely before each render. The system is designed to be as deterministic as possible.

Additionally, this allows the implementation of React-like "hooks" - and the introduction of reactive state.

Imbuing Reactivity

Let's first take a look at the usage of our useState function, then we can delve into how it works. Let's take the stub view I showed earlier and turn it into a simple interactive counter example:

async function Counter({ router }) {
    const [counter, setCounter] = router.useState(0);
    const root = jQuery(/*html*/`
        <div>
            <button id="remove-one">-1</button>
            ${counter}
            <button id="add-one" onClick="${() => setCounter((curr) => curr+1)}">+1</button>
        </div>
    `);
    root.find('#add-one').on('click', () => setCounter((curr) => curr+1));
    root.find('#remove-one').on('click', () => setCounter((curr) => curr-1));
    return {
        root: root,
        hydrate: () => {},
    }
}
class ViewRoute {
    constructor(primary) {
        this.primary = primary;
        ...
        this.signals         = [];
        this.signalsCursor   = 0;

        this.onces           = [];
        this.oncesCursor     = 0;

        this.state           = [];
        this.stateCursor     = 0;
        ...
    }
}

At the beginning of each render, these "cursors" are reset. ELABORATE