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.

Why pushing to everyone is cheap: the attribute cache

updateAll() calls .$() on every component on every tick, which re-renders each component's whole subtree. Brute force. It should be wasteful - and in a virtual-DOM framework, the diff cost would scale with the tree. In mx it doesn't, because render() keeps a per-element cache of the attributes it last set and skips the DOM entirely for anything that hasn't changed.

// Render the same description twice. The second pass touches the DOM
// for nothing - every attribute already matches the cache.
row.render(mx.div({ class: 'cell', title: name }, name));
row.render(mx.div({ class: 'cell', title: name }, name));   // 0 setAttribute calls

// Change one thing, and only that one thing is written:
row.render(mx.div({ class: 'cell',        title: name }, name));
row.render(mx.div({ class: 'cell active', title: name }, name));
// -> a single setAttribute('class', ...). title and the text node are untouched.

Walking the description and comparing values still happens every render - but a comparison is a property read and an === check, a few nanoseconds. What it avoids is the expensive part: the setAttribute or property write that makes the browser recompute layout and style.

The cache, concretely

Every element mx renders into carries a hidden _$ object - the values it last applied - plus a stamp map and a render counter:

// After a render, each element carries a small private cache of what mx set:
//   el._$         last-applied attribute values   e.g. { class: 'cell', title: 'Ada' }
//   el._$.__      a stamp map for event handlers / special props
//   el._$._       a monotonic render counter, used to retire stale handlers
//
// Next render, for every incoming attribute mx does:
//   if (value === el._$[key]) continue;   // already correct -> skip the DOM
//   else { setAttribute / set property; el._$[key] = value; }
//
// The comparison is a property read and an === check: a few nanoseconds.
// A setAttribute that the browser turns into a style invalidation is far
// more expensive - so the cache trades cheap JS for avoided browser work.

This is the _$ you'll see if you inspect a live mx element. It's not framework bookkeeping you maintain; it's how the reconciler stays honest about what the DOM already holds.

Why skipping setAttribute matters so much

A setAttribute isn't just a property write - it tells the browser the element might look different, so the element gets marked style-dirty and its styles get recomputed on the next recalc. When mx skips the write because the value is unchanged, the browser is never told anything changed, so it never recalculates that element's style. The cache isn't a JS micro-optimization; it's a way to keep work off the browser's critical path.

What this buys, measured

  • ~97% cache hit rate on a live 30-asset, 20 Hz dashboard: of ~1,543 nodes walked per tick, only ~44 produce an actual DOM mutation.
  • 6× cheaper style recalc per invalidation than React/Vue/Angular - mx's dirty-element set per recalc is a fraction of theirs because most elements are never marked dirty.
  • 0.78 µs to diff one node (~3,100 CPU cycles) - close to the hardware floor for touching a DOM node in JavaScript.
  • Allocation-free in steady state: descriptions are tiny arrays discarded after the walk; the cache is one plain object per element. A 500-operation run retains ~1.1 KB of heap, versus megabytes for frameworks that allocate a node set per render.

The reconciler's cost is the tree walk, not the mutations - which is why mx scales flat. Re-rendering a subtree where one leaf changed costs essentially the same as one where five hundred leaves changed: the walk is identical, and the cache absorbs the difference at the DOM boundary. (The deep-tree numbers are in the benchmark write-ups; the short version is "mx is O(tree), and the tree walk is cheap.")

Handlers clean themselves up too

The stamp map (_$.__) and render counter (_$._) handle event handlers. Each render stamps the handlers it sets with the current counter; any handler still carrying an old stamp afterward - i.e. present last render, absent this one - is nulled automatically. That's why you never manually remove a handler set through a render description, and why re-rendering can't stack listeners. (Handlers you add to document/window yourself are a different matter - see Lifecycle.)

Why you don't memoize

Because no-op renders are nearly free, wrapping components in memoization usually adds bookkeeping that costs more than the render it "saves." Measure before caching; the answer is almost always "don't." See What mx Is Not → No Memoization.

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.

Upgrading Server-Rendered Custom Elements

For plain HTML elements the reconciler is the whole story - the SSR'd <div> becomes the live <div> and mx patches its attributes from there. Custom elements (<my-navbar>, <goal-card>) need one extra step: the element exists in the DOM, but doesn't yet have the methods from define() attached. Until you attach them, .$() is undefined.

mx attaches methods inside createElement - it does Object.assign(element, components[tag]) on every fresh node. Server-rendered elements weren't created by mx, so they need a one-time upgrade pass after the define() calls have run:

// In your app entry point, after all define() calls have executed:
document.querySelectorAll(Object.keys(components)).forEach(el =>
    !el.$ && Object.assign(el, components[el.localName]));

The array gets implicitly stringified to a comma-separated selector list - querySelectorAll coerces its argument to DOMString via the WebIDL spec, which calls Array.prototype.toString (i.e. join(',')). One pass over the DOM, one lookup per element by localName.

That's the entire upgrade. Every server-rendered custom element now has $, $attrs, and any other methods from its definition. From here it behaves identically to one created via mx.

When to call .$() afterwards

Some upgraded elements need to render with current state - the navbar shows the logged-in user, the goal card shows live progress. For those, call .$(state) explicitly after the upgrade:

navbarEl.$({ path: location.pathname, user: currentUser });

For static elements (footer, terms-link, anything whose appearance doesn't depend on runtime state) the SSR'd HTML is already correct - no .$() call needed. The methods are attached and will respond if you ever call them later, but their initial paint is whatever the server emitted.

When the upgrade is unnecessary

If your SSR'd HTML doesn't use any custom elements - everything is plain <div>/<span>/<a> - skip the upgrade pass entirely. The reconciler handles plain elements without help. Custom elements only need the upgrade because they have methods that exist outside the HTML.

Where this comes from

The pattern is just iterating the registry and matching it against the DOM - there's no framework magic involved. components is a global object whose keys are tag names; querySelectorAll finds matching elements; Object.assign transplants the methods. If you wrote those four lines yourself without ever seeing this page, you'd arrive at the same code. The framework doesn't hide the upgrade because the upgrade is trivial and you might want to customize when it runs (after auth check, after route resolution, lazily per-element on visibility).

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+.