Search code examples
javascriptreduxdesign-patternssingle-page-applicationsoc

Is this pattern okay for reasonnably small JS applications?


I want to refactor a number of JS single-page applications that I've programmed not using any particular pattern. Now I've read about interesting frameworks (redux...), but my company is not keen to adopt frameworks in general, everyone here is using vanilla JS. So I want to keep things homemade and as simple as possible. The most obvious defect I find in my old code is the monolithic style, so it seems that introducing component-based architecture with separation of concern would be already a huge improvement. Here is a mock-up I came up with:

let eventGenerator = (function () {
    let id = -1;
    return {
        generate: () => {
            ++id;
            return id;
        }
    };
}) ();

let dispatcher = (function () {
    let components = [];
    return {
        addComponent: (component) => {
            components.push (component);
        },
        dispatch: (id, detail = null) => {
            for (let c of components) {
                c.handleEvent (id, detail);
            }
        }
    };
}) ();


const EVT_FAKE_API_RUNNING = eventGenerator.generate ();
const EVT_FAKE_API_SUCCESS = eventGenerator.generate ();
const EVT_FAKE_API_FAILURE = eventGenerator.generate ();
const EVT_FAKE_API_ABORTED = eventGenerator.generate ();
class ComponentFakeAPI {
    constructor (param) { // param = nb de secondes à attendre
        dispatcher.addComponent (this);
        this.param = param;
        this.timer = null;
        this.result = null;
    }
    handleEvent (id, detail) {
        switch (id) {
        case EVT_FETCH_BUTTON_CLICKED:
            this.timer = setTimeout (() => {
                this.result = Math.round (Math.random () * 100);
                if (this.result >= 20)
                    dispatcher.dispatch (EVT_FAKE_API_SUCCESS, { result: this.result });
                else
                    dispatcher.dispatch (EVT_FAKE_API_FAILURE);
            }, this.param);
            dispatcher.dispatch (EVT_FAKE_API_RUNNING);
            break;
        case EVT_ABORT_BUTTON_CLICKED:
            clearTimeout (this.timer);
            dispatcher.dispatch (EVT_FAKE_API_ABORTED);
        }
    }
}


const EVT_FETCH_BUTTON_CLICKED = eventGenerator.generate ();
class ComponentFetchButton {
    constructor (elt) {
        dispatcher.addComponent (this);
        elt.innerHTML = `<button>fetch</button>`;
        this.elt = elt;
        this.but = elt.querySelector ('button');
        this.but.onclick = () => dispatcher.dispatch (EVT_FETCH_BUTTON_CLICKED);
    }
    handleEvent (id, detail) {
        switch (id) {
        case EVT_FAKE_API_RUNNING:
            this.but.disabled = true;
            break;
        case EVT_FAKE_API_SUCCESS: case EVT_FAKE_API_FAILURE: case EVT_FAKE_API_ABORTED:
            this.but.disabled = false;
            break;
        }
    }
}

const EVT_ABORT_BUTTON_CLICKED = eventGenerator.generate ();
class AbortButton {
    constructor (elt) {
        dispatcher.addComponent (this);
        elt.innerHTML = `<button disabled>abort</button>`;
        this.elt = elt;
        this.but = elt.querySelector ('button');
        this.but.onclick = () => dispatcher.dispatch (EVT_ABORT_BUTTON_CLICKED);
    }
    handleEvent (id, detail) {
        switch (id) {
        case EVT_FAKE_API_SUCCESS: case EVT_FAKE_API_FAILURE: case EVT_FAKE_API_ABORTED:
            this.but.disabled = true;
            break;
        case EVT_FAKE_API_RUNNING:
            this.but.disabled = false;
            break;
        }
    }
}

class ComponentValueDisplay {
    constructor (elt) {
        dispatcher.addComponent (this);
        elt.textContent = '';
        this.elt = elt;
    }
    handleEvent (id, detail) {
        switch (id) {
        case EVT_FAKE_API_SUCCESS:
            this.elt.textContent = detail.result;
            break;
        case EVT_FAKE_API_FAILURE:
            this.elt.textContent = 'failure !';
            break;
        case EVT_FAKE_API_ABORTED:
            this.elt.textContent = 'aborted !';
            break;
        case EVT_FAKE_API_RUNNING:
            this.elt.textContent = '';
            break;
        }
    }
}

class ComponentAverage {
    constructor (elt) {
        dispatcher.addComponent (this);
        elt.textContent = '';
        this.elt = elt;
        this.sum = 0;
        this.avg = 0;
        this.n = 0;
    }
    handleEvent (id, detail) {
        switch (id) {
        case EVT_FAKE_API_SUCCESS:
            ++ this.n;
            this.sum += detail.result;
            this.elt.textContent = Math.round (this.sum / this.n);
            break;
        }
    }
}


window.addEventListener ('load', () => {
    let componentFakeAPI = new ComponentFakeAPI (2000);
    let componentFetchButton = new ComponentFetchButton (document.querySelector ('#componentFetchButton'));
    let componentAbortButton = new AbortButton (document.querySelector ('#componentAbortButton'));
    let componentValueDisplay = new ComponentValueDisplay (document.querySelector ('#componentValueDisplay'));
    let componentAverage = new ComponentAverage (document.querySelector ('#componentAverage'));
});
#componentValueDisplay, #componentAverage {
    margin-left: 10px;
    border: 1px solid black;
    min-width: 50px;
}
<div style="display: flex">
    <div id="componentFetchButton"></div>
    <div id="componentAbortButton"></div>
    <div>Result</div>
    <div id="componentValueDisplay"></div>
    <div>Average</div>
    <div id="componentAverage"></div>
</div>

I'm wondering if this pattern will hit a wall at some point in a bigger, more complex application. Any advice?


Solution

  • my company is not keen to adopt frameworks in general, everyone here is using vanilla JS

    Curious, but I'm familiar with constraints outside of your control so I'll skip the whole "avoid reinventing the wheel" spiel.

    So, you have two things here going for you: events (observer pattern) and components (composite pattern, sort of). The former will help you to avoid direct dependencies between components while the latter will help you encapsulate logic (and potentially construct component trees). Both will serve you well as the application grows and what you have should suffice assuming you iterate on these patterns as the application outgrows them and increases in complexity.

    Based on your example code, I do want to provide two recommendations. Take it as you wish.

    First, I would modify the dispatcher to follow a more traditional event emitter / observer API. That is to say, have it group events by type. This will improve things in a few ways:

    1. The dispatcher will only need to notify event handlers that are subscribing to a particular event. That way you don't need to iterate over potentially hundreds or thousands of components (subscribers) as the application grows.

    2. The handleEvent methods could then be split up and handle specific events and you can skip all of the switch statements. You can also reuse the same event handler for multiple events, such as disabling/enabling the buttons. Just be mindful of using the this keyword!

    3. Events can now be named and you can skip the eventGenerator, e.g.:

      const AppEvents = {
        api: {
          running: 'api.running',
          succees: 'api.success',
          failure: 'api.failure',
          aborted: 'api.aborted'
        }
      };
      
      dispatcher.subscribe(AppEvents.api.running, (event) => {
        // do something
      })
      
      // later
      dispatcher.notify(AppEvents.api.running, someEventData)
      

    Second, to improve ease of testing, consider providing the dispatcher as an argument to the components. It may not seem important for the dispatcher specifically, but it does help you stay consistent with how components consume external dependencies. And in the case of testing, you can more easily provide mocks or stubs when needed.

    Bonus tip: avoid using #ids in CSS. Their styles are harder to override and they're also less reusable.


    In the end, the code you have works, which from a business perspective is enough. But for you as a developer, it's a matter of how easy it is to understand, maintain, and add additional features to. Most importantly, you'll want to get buy-in from your peers/colleagues so they also understand how and why they should follow these patterns. Good luck!