Writing dependency-free JavaScript

by
2020-01-11

Lately, I have been working on a web application, pixu.rs1, that, while small, includes an interactive form with image uploading and multiple phases. This is exactly the kind of thing I tend to turn into a mess when I'm not using React, Redux and everything that comes with that ecosystem, but small enough that I hesitate to bring in the big guns. For pixu.rs, I have experimented with applying learnings from the React ecosystem to my code without actually using any dependencies, and in the process I have discovered a few techniques I thought were valuable enough to write down.

I will not motivate the decision to forgo dependencies in this post. I will, however, note that if you have the motivation, you must be prepared to make some concessions. The biggest and most obvious one is that you lose the immediate mode rendering React implements. These are my tips for making the best of it.

Tip #1: Create the DOM up front

React is excellent for getting control of your dynamically generated HTML. Doing without React means letting go of this. Instead, I have had good experience with generating all the HTML I need up front on the server and then using JavaScript to show and hide different parts of the document as needed. This works well for the appropriate kinds of dynamic HTML, where the user sees different "pages" at different times and the set of possible states is statically known at the outset.

For content that must be generated dynamically, you can still create a template of it in your HTML document. Hide it with CSS and then use Node.cloneNode() to instantiate it. This lets you off with only very few DOM API calls to generate HTML.

Update: Since posting this, I have been notified that this is a well-trodden path that has a few tricky corner cases. As alternatives to including the template directly in your HTML, there is also the trick of including it in a <script type="text/html">-tag, instantiating it via innerHTML and the more recent <template> element, both of which are responses to the corner cases, I believe.

Tip #2: Centralize rendering

Take inspiration from React and centralize all the rendering code to one function, render(...). Letting go of immediate mode rendering means we have to look at the changes in state to see which changes we have to do to the DOM. To facilitate this, our rendering function will have two parameters: prevState and nextState. Structure the render function in blocks that test for changes in the state and make the relevant updates to the DOM:

function render(prevState, nextState) {
    if (prevState.count != nextState.count) {
        document.querySelector('.counter--display').textContent = nextState.count;
    }

    // And so on...
}

This is far from as nice as React rendering code, but it is nicer than a sprawling mess of unstructured DOM updates and it is fully possible to maintain.

Add setState(...) and updateState(...) so you don't have to call render directly:

let state;

function setState(nextState) {
    render(state, nextState);
    state = nextState;
}

function updateState(delta) {
    const nextState = { ...state, ...delta };
    setState(nextState);
}

This simple implementation of updateState means your state object cannot be nested. You may have to update your naming scheme to fit this, but it works just fine in practice. Alternatively, you can have a more elaborate updateState function. But isn't there beauty in keeping it this simple?

Tip #3: Keep the compile step

I started out writing my JavaScript to run in the browser directly, without either compiling or minifying. After a while, it turned out that I had inadvertently broken compatibility with one of my target platforms. At this point I was faced with the choice of either debugging it on somebody else's device without developer tools… or simply throwing Babel at it.

In the end I decided that making use of Webpack and Babel would do me more good than harm and let me keep most of the advantages of trying to go dependency-free. I like to think that these tools are more easily replaceable than library dependencies: the interface is very high level (JS→JS) and I would not have to rewrite any code if I were to swap these out for equivalents.

I have been very happy with this decision.

Tip #4: The DOM object

When you render all the HTML up front, you have to find the DOM objects all the time to manipulate them. Centralize this to one constant that you fill in at the beginning to make it easier to read and write as well as improving execution speed:

const DOM = {
    imageInput: document.getElementById('image'),
    preview: document.querySelector('.preview'),
    // ...
};

Unlike the state object, the DOM object may well be nested, to reflect the structure of your application.

Tip #5: Keep state in the DOM

T'ain't no sin to let the canonical storage for a text input be the input DOM element. The trick is to be able to identify when it is time to move it into the state object, like you are supposed to do with React.

Storing the value in the DOM element is sufficient when you only read from it in response to other user actions, such as clicking a submit button. Storing the value in the state object is a good idea when the UI is responding interactively to it, for example by updating a text field or performing form validation.

Keeping the state in the DOM element, when possible, saves you a lot of boiler plate code.

Tip #6: Cheat

Know and love alert, confirm and prompt!

These are excellent tools for prototyping, but you might even consider them for your finished application if you only use them on an administration page or some other part with a very limited audience.

If you put them in, and you later want to replace them with modal dialog boxes implemented in HTML and JS, you can get far with an async implementation. Update your state to show the dialog and return a Promise which resolves with the proper user interaction. Voilà!

function asyncAlert(message) {
    updateState({
        alert: message,
    });

    return new Promise(function (resolve) {
        DOM.alertOkButton.onclick = function () {
            updateState({
                alert: null,
            });
            resolve();
        };
    });
}

Tip #7: Redux

So what about Redux? I haven't gotten as far as taking much inspiration from Redux, so calling this a tip is taking a liberty. But it also brings up a point I like about this dependency-free experiment: You don't have to build a framework-replica up front. Refactor as you see the need for better things.

For Redux-inspiration, I'm pretty sure you can get far with writing ad hoc reducers along with a simple dispatch implementation:

function rootReducer(state, action) {
    return {
        subsystem1: subsystem1Reducer(state.subsystem1, action),
        subsystem2: subsystem2Reducer(state.subsystem2, action),
        // and so on...
    };
}

function dispatch(action) {
    let newState = rootReducer(state, action);
    setState(newState);
}

This should allow you to have a structured state object as well!

Conclusion

And that's it! Those are my concrete experiences (plus one imaginary one) with writing JavaScript without library dependencies:

  1. Create the DOM up front
  2. Centralize rendering
  3. Keep the compile step
  4. The DOM object
  5. Keep state in the DOM
  6. Cheat
  7. Redux

Now, you go and write dependency-free JavaScript today! I mean, if you want to. Or not. But if you ever get the urge, maybe this can be a good starting point.


1

You are supposed to pronounce it like "pictures". The trailing ".rs" signifies that it is written in Rust. Yes, it is a very clever name.

, 2020