Structuring Single-Page Applications with Avers

This is a overview how I structure single-page applications with the help of Avers and related libraries. The structure evolved over the course of the last few years during which I worked on several small and large applications. The applications are all of the same type (manage and edit data which is in the background synchronized with a server), and I understand that the particular structure may not fit all use cases. However there are certain concepts which are useful in general.

As I was creating more and more of these applications, I identified the functionality they have in common and tried to extract it into multiple libraries. They are collectively maintained in the avers repository on GitHub.

This extraction of common functionality is an ongoing process, I still keep finding features which I can extract from the individual apps and put into avers.

Strict separation between data and DOM UI

I view the DOM as a particular implementation of a user interface. For me as a programer though there is a much more fundamental UI: the console. When I start with a new application, I first define the data types and functions how to interact with those. Just enough so that I can do all necessary inteactions from the developer console. Register a new account, sign in, create objects, edit them, etc.

Only when I'm reasonably certain that my data and interaction models are sufficient to implement the higher level UI, I start working on the DOM UI. That means for the first few hours or days I'll stare at a blank web page, have the developer console open and play around with the application purely through calling functions and observing what happens with the data. I'll make sure that I have access to the correct data and can manipulate it.

This separation makes it easy to swap out the DOM UI for a different type of UI (for example React Native), while keeping the same underlying data and interaction models.

Generation counter

To render the DOM UI I use React. When using React it is important to keep track of every change to the underlying data, because when it changes I need to re-render the UI. For that change tracking I use a simple counter and Object.observe.

This functionality is so fundamental to all applications that it is part of what I extracted into avers. The library implements all logic for synchronizing data between client and server and also provides the generation counter.

I need to be careful to increment the counter whenever something changes. This may look like it has lots of potential for bugs. But since all data manipulation is handled by the avers library, this hasn't been a problem in practice.

The DOM UI

The DOM UI layer contains the logic necessary to render the UI in a web browser. Part of this layer is for example the router, mapping the location path to a function which generates a React element. If the application needs to manage any additional data that is specific to the DOM UI then it's also stored in this layer.

This part is difficult to extract into a common library. The user experience which the application tries to present plays a big role here, and that is different in each application.

Sometimes I also make use of the React Component local state. When used judiciously it provides the right abstraction to make components self-contained and thus easier to reuse.

TypeScript and ES6 modules

I use TypeScript to bundle all files into a single output file and then run the output file through babel to compile any ES6 features which are not available in current browsers (eg. class syntax). The compile times are reasonably short (~5 seconds for a medium sized projects).

Bundling all sources into a single file makes deployment easy and the toolchain around it is mature. It's convenient for us developers but less so for users. With each change, which in a large team can happen multiple times a day, the users have to download the whole bundle again. This has devastating effects on the page load times. Once I figure out a good workflow with ES6 modules I'll start using them. However as it currently stands the tooling is just not there yet.

To reduce the number of globals, all my code is namespaced in a single top-level TypeScript module, usually named after the application (eg. bt in my battery tracker app). The top-level module is further split up into sub-modules (eg. bt.data, bt.Storage, bt.Views etc).

Avers, Avers.Handle, Avers.Session

The Avers library (which are actually three different libraries which build on top of each other) implements most of the functionality needed in the data layer.

The base library (avers.ts) provides functionality to create objects and track changes to them. The second library (avers.storage.ts) manages these objects and knows how to synchronize them with the server (which must implement a compatible HTTP API). The third library (avers.session.ts) provides a simple session API.

Code samples

These are samples taken straight from one of my apps. It's an application which I use to track charge cycles of my LiPo batteries. The data it manages is rather simple, I have batteries and events (charge/discharge). The app has a concept of user accounts to allow multiple users manage their own batteries.

The Avers.Handle manages the objects and synchronization with the server. It also contains the generation counter. Avers.Session keeps track of the signed in user. Next to that I have two collections, one are the batteries which are owned by the current user, and for each battery its events.

module bt {
    module Storage {
        export class Battery {
            owner : string; // ObjId of the battery owner.
            type  : string; // "lipo" | "li-ion" | "nimh" | ...
        }

        Avers.definePrimitive(Battery, 'owner');
        Avers.definePrimitive(Battery, 'type', 'lipo');
    }

    module data {

        export var aversH = new Avers.Handle
            ( bt.config.apiHost
            , fetch.bind(window)
            , window.performance.now.bind(window.performance)
            , infoTable
            );

        export var ownedBatteriesCollection =
            new Avers.ObjectCollection(aversH, 'ownedBatteries');

        export var batteryEventsCollection =
            new Avers.KeyedObjectCollection(aversH, (batteryId: string) => {
                return 'batteryEvents/' + batteryId;
            });

        export var session = new Avers.Session(aversH);
    }
}

With that in place I can already play around with the data on the console. Sign up, create a battery, see which ones I've already created etc.

Avers.signup(bt.data.session).then(accountId => {
    return Avers.signin(bt.data.session, accountId);
});

var bat = Avers.mk(bt.Storage.Battery, {
    owner: bt.data.session.objId, type: 'lipo'
});
Avers.createObject(bt.data.aversH, 'battery', bat);

var batteryIds = bt.data.ownedBatteries.ids.get([]);

The DOM UI layer only contains the function which is used to generate the React element. To update the UI I call that function to create the React element and render it into the document body.

module bt {

    export type MkView = () => React.Element;

    // The initial view to show when the application starts up.
    function loadingView(): React.Element {
        return React.DOM.div({}, "loading...");
    }

    // The DOM UI layer.
    export module app {
        export var mkView : MkView = loadingView;
    }

    // Call this whenever the DOM needs to be re-rendered.
    export function refresh() {
        React.render(bt.app.mkView(), document.body);
    }
}

The main entry point into the application does some one-time initialization and sets up the watcher for the generation counter. From then on the whole application is driven by that counter.

module bt {
    export function bootstrap() {
        // Register the onpopstate event listener and watch for route changes.
        // When the route changes then the handler will set `bt.app.mkView`
        // to a function which creates the corresponding view.
        setupRoutes();

        // Check with the server if we're currently logged in or not.
        Avers.restoreSession(data.session);

        // Observe the Avers handle and refresh the app UI when the
        // generation counter changes.
        Object.observe(bt.data.aversH, function(records) {
            var changedGeneration = records.some(rec => {
                return rec.name == 'generationNumber';
            });

            if (changedGeneration) {
                bt.refresh();
            }
        });
    }
}