Architecture Patterns

Patterns for building full applications with mx.js - from single-file sites to large-scale SPAs.

App Shell

Every mx app starts with a shell: render the layout once, get persistent references, wire events.

// 1. Render the app shell
let app = document.getElementById('app');
app.render(
    mx('sidebar-nav', { currentPath: '/' }),
    mx.div({ id: 'main' }),
    mx('player-bar')
);

// 2. Get persistent references to shell components
let sidebar = app.querySelector('sidebar-nav');
let mainContent = app.querySelector('#main');
let playerBar = app.querySelector('player-bar');

// 3. Wire cross-component events
events.on('track:play', data => {
    playerBar.$({ track: data.track, isPlaying: true, progress: 0 });
});

// 4. Navigate to initial route
navigate(location.pathname);

The shell components (sidebar-nav, player-bar) are created once and updated via .$(). The #main container swaps content on navigation. This is the foundation of every mx SPA.

The Update-All Pattern

For apps with many components sharing state (dashboards, real-time feeds), use a single updateAll() function that pushes derived state to every component:

let selectedAssetId = null;
let activeSector = null;

function updateAll() {
    let derived = computeDerived();
    let filtered = getFilteredAssets();

    // Push state to all components in one pass
    ticker.$({ assets: ASSETS });
    watchlist.$({ assets: filtered, selectedId: selectedAssetId });
    priceChart.$({ asset: assetById(selectedAssetId) });
    kpiValue.$({ value: derived.portfolioValue, label: 'Portfolio' });
    movers.$({ gainers: derived.gainers, losers: derived.losers });
}

// Everything funnels through updateAll
feed.on(updateAll);
events.on('asset:select', id => { selectedAssetId = id; updateAll(); });
events.on('filter:sector', s => {
    activeSector = activeSector === s ? null : s;
    updateAll();
});

This pattern scales to 10+ components without complexity. Compute derived values once, push to all. No dependency tracking, no subscriptions - just function calls.

Why not reactive signals?

Signals add implicit dependencies. updateAll() is explicit: you see exactly what updates and when. For dashboards with 50ms data feeds, explicitness prevents cascade bugs.

Event Bus

Components communicate upward via callback props. For cross-component coordination (sibling → sibling, or any → app), use an event bus:

let events = (_ => {
    let listeners = new Map;
    return {
        send(event, data) {
            listeners.get(event)?.forEach(fn => fn(data));
        },
        on(event, fn) {
            let s = listeners.get(event);
            s ? s.add(fn) : listeners.set(event, new Set([fn]));
        }
    };
})();

// Component fires event
onclick: _ => events.send('asset:select', asset.id)

// App listens and orchestrates
events.on('asset:select', id => {
    selectedAssetId = id;
    updateAll();
});

Common events from production:

EventDataFired byHandled by
asset:selectidTable row, card clickApp → updateAll
filter:sectorsectorIdFilter pillApp → updateAll
track:play{ track, queue }Play buttonApp → player.$()

Routing

Two approaches depending on app complexity:

Navigation API (modern SPA)

let routes = { '/': 'home', '/contact': 'contact' };

function navigate(path) {
    let view = routes[path] || 'not-found';
    navbarEl.$({ path });
    window.scrollTo(0, 0);
    views[view].init(contentEl);
}

// Navigation API (modern SPA)
navigation.addEventListener('navigate', e => {
    let url = new URL(e.destination.url);
    if (url.origin !== location.origin) return;
    e.intercept({ handler: _ => navigate(url.pathname) });
});

Lazy-loaded views

For larger apps, load view scripts on demand:

function loadView(name) {
    return new Promise((resolve, reject) => {
        if (views[name]) return resolve();

        // Check if script already in DOM (from SSG)
        let existing = document.querySelector(
            'script[src="/views/' + name + '.js"]'
        );
        if (existing) {
            let check = _ => views[name]
                ? resolve()
                : requestAnimationFrame(check);
            return check();
        }

        // Load dynamically
        let s = document.createElement('script');
        s.src = '/views/' + name + '.js';
        s.onload = resolve;
        s.onerror = reject;
        document.head.append(s);
    });
}

async function navigate(path) {
    let view = parseRoute(path);
    contentEl.render(mx('q-spinner'));    // Show loading
    await loadView(view);                 // Load on demand
    views[view].init(contentEl);          // Render
}

Views register themselves in a global views object on load. The router shows a spinner while the script downloads, then calls init().

View Pattern

Views are IIFE modules with init and render. State lives in the closure:

views.dashboard = (_ => {
    let vm = {};
    let goals = null;

    vm.init = container => {
        goals = null;
        vm.render(container);       // Show spinner
        api.goals()
            .then(data => { goals = data; vm.render(container); })
            .catch(_ => { goals = []; vm.render(container); });
    };

    vm.render = container => {
        container.render(
            goals === null
                ? mx('q-spinner')
                : !goals.length
                    ? mx.div({ class: 'empty' }, 'No goals yet')
                    : mx.div({ class: 'grid' },
                        ...goals.map(g => mx('q-goal-card', g))
                    )
        );
    };

    vm.hydrate = vm.init;
    return vm;
})();

The pattern: render immediately (show loading), fetch data, re-render with results. The closure holds view-local state between navigations.

Static HTML with templates

For static sites (no dynamic data), use <template> elements instead of JS rendering. Content is in the HTML - indexable, instant paint, zero JS rendering cost:

<!-- Static HTML in <template> tags -->
<template id="v-home">
  <section class="hero">
    <h1>We build <span id="hero-rotate">small, powerful</span>
        web applications.</h1>
    <a href="/contact" class="btn">Get in Touch</a>
  </section>
  <footer>...</footer>
</template>

<script>
function navigate(path) {
    let view = routes[path] || 'not-found';
    navbarEl.$({ path });

    // Clone static template into content area
    let tpl = document.getElementById('v-' + view);
    contentEl.textContent = '';
    contentEl.append(tpl.content.cloneNode(true));

    // Wire up SPA links on the cloned content
    contentEl.querySelectorAll('a[href^="/"]').forEach(a => {
        a.onclick = e => {
            e.preventDefault();
            navigation.navigate(a.getAttribute('href'));
        };
    });
}
</script>

Real-Time Data

For live data (WebSocket, polling, simulated feeds), mutate objects in place and push to components:

let feed = (_ => {
    let listeners = new Set;
    let interval;

    function tick() {
        let count = 2 + Math.floor(Math.random() * 4);
        let seen = new Set;
        for (let i = 0; i < count; i++) {
            let asset = ASSETS[Math.floor(Math.random() * ASSETS.length)];
            if (seen.has(asset.id)) continue;
            seen.add(asset.id);
            // Mutate in place - no cloning
            asset.price += (Math.random() - 0.5) * asset.price * 0.002;
            asset.history.push(asset.price);
            if (asset.history.length > 60) asset.history.shift();
        }
        listeners.forEach(fn => fn());
    }

    return {
        start() { interval = setInterval(tick, 50); },
        stop()  { clearInterval(interval); },
        on(fn)  { listeners.add(fn); },
        off(fn) { listeners.delete(fn); }
    };
})();

// Wire to app - feed mutates data, updateAll pushes to components
feed.on(updateAll);
feed.start();

Key principles

  • Mutate in place - don't clone objects. Components read latest values on render.
  • Deduplicate per tick - each item updated at most once per cycle.
  • Cap history - prevent unbounded memory growth.

Frame-coalesced updates

When the feed fires faster than the screen refreshes, coalesce updates to one render per animation frame:

// Coalesce rapid updates into one render per frame
function rafThrottle(fn) {
    let scheduled = false;
    return _ => {
        if (scheduled) return;
        scheduled = true;
        requestAnimationFrame(_ => {
            scheduled = false;
            fn();
        });
    };
}

// Feed fires 20x/sec, but UI renders at most once per frame
let scheduleUpdate = rafThrottle(updateAll);
feed.on(scheduleUpdate);

This is what React and Vue do globally. With mx, you control it per component or at the app level.

API Layer

Wrap fetch in a thin API client with auth handling:

let api = (_ => {
    let base = window.API_BASE || '';

    async function request(path, options = {}) {
        let { method = 'GET', body } = options;
        let opts = { method, credentials: 'include', headers: {} };
        if (body) {
            opts.headers['content-type'] = 'application/json';
            opts.body = JSON.stringify(body);
        }
        let res = await fetch(base + path, opts);
        if (res.status === 401) {
            auth.user = null;
            navigation.navigate('/login');
            throw new Error('Unauthorized');
        }
        let data = await res.json();
        if (!res.ok) throw new Error(data?.error || 'Request failed');
        return data;
    }

    return {
        me:    _ => request('/api/auth/me'),
        goals: _ => request('/api/goals'),
        goal:  slug => request('/api/goals/' + slug),
        createGoal: data => request('/api/goals', { method: 'POST', body: data }),
    };
})();

Keep it simple: credentials for cookies, JSON in/out, 401 redirect. Add methods as needed.

Lazy-Loaded Components

When a component wraps a heavy external library (CodeMirror, chart engine, map SDK), lazy-load the library and defer rendering until it's ready:

define('code-editor', {
    $({ value = '', disabled, onChange }) {
        let me = this;
        me.$attrs({ disabled });

        // One-time init: load heavy library, create persistent container
        if (!me.$state.initialized) {
            me.$state.initialized = true;
            me._container = dom.div();

            ui.require('codemirror').success(_ => {
                me.$editor = CodeMirror(me._container, {
                    lineNumbers: true,
                    mode: 'javascript'
                });
                me.$editor.on('change', _ => onChange?.(me.$editor.getValue()));
                me.$editor.setValue(me.$state.value || '');

                // Wait until element is in DOM before layout
                let ready = _ => me.isConnected
                    ? me.$editor.refresh()
                    : requestAnimationFrame(ready);
                requestAnimationFrame(ready);
            });
        }

        // Sync value from parent on subsequent renders
        if (me.$editor && me.$editor.getValue() !== value)
            me.$editor.setValue(value);

        return [me._container];
    }
});

Key patterns:

  • $state.initialized - one-time init guard that survives parent re-renders
  • this._container - persistent DOM element returned before library loads
  • requestAnimationFrame poll - waits until element is in DOM before calling layout-dependent APIs
  • me.$editor - library instance on the element for external access

Static Site Generation

mx's reconciler walks existing DOM children. If the HTML is already in the page (from SSG, hand-written markup, or server render), mx takes over without re-rendering - parasitic hydration:

// Build step: pre-render with happy-dom (no browser needed)
import { parseHTML } from 'linkedom';

let { document } = parseHTML('<!doctype html><html><body></body></html>');

// Load mx.js globals into the fake DOM environment
eval(fs.readFileSync('ui-mx.min.js', 'utf8'));

// Load components
eval(fs.readFileSync('components/navbar.js', 'utf8'));

// Render a view
let container = document.createElement('div');
views.home.init(container);

// Output static HTML
let html = container.innerHTML;
fs.writeFileSync('dist/index.html', wrapInShell(html));

// mx's reconciler reuses existing DOM on client load
// - no hydration markers needed, just structurally matching HTML

No hydration markers, no data-reactroot, no client/server contract. The reconciler reuses matching nodes by tag name. Mismatches are patched silently.

Scaling to 200+ Components

mx.js scales to hundreds of components. Patterns that enable this:

  • No modules - concatenate files in order via build config. No import/export, no bundler.
  • Global namespaces - ui.*, api.*, mx.*. Cleaner for cohesive teams.
  • Lazy fragments - complex workflows load on demand, reducing initial bundle.
  • View configuration as data - declarative column/filter definitions drive enterprise-level table rendering.
  • Cell processors - functions that transform raw API values into DOM (links, badges, time formatting).
  • Build-time i18n - translation tokens replaced at build time, not runtime. No translation library needed.

Bundle sizes at scale

A production app with 200+ components: ~100KB JS + 50KB CSS (gzipped). Compare with React apps of similar scope: typically 500KB-1MB+.