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:
| Event | Data | Fired by | Handled by |
|---|
asset:select | id | Table row, card click | App → updateAll |
filter:sector | sectorId | Filter pill | App → updateAll |
track:play | { track, queue } | Play button | App → 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-rendersthis._container - persistent DOM element returned before library loadsrequestAnimationFrame poll - waits until element is in DOM before calling layout-dependent APIsme.$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 HTMLNo 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+.