What mx Is Not
mx is a reconciler, not a framework. It deliberately omits a lot of what React, Vue, and Svelte provide. If you're coming from one of those, this page is the translation table for what's missing and what replaces it.
The short version: mx has render, state, and components. Everything else is plain JavaScript.
No Hooks
No useState, no useEffect, no useMemo, no useRef, no useContext, no rules-of-hooks.
Replacements:
useState → this.$state.x and this.$({ x: newValue })useEffect → code that runs directly in $(). For cleanup, use clearInterval/clearTimeout at the top of $. For "mount" logic, guard with this._initialized ||= true.useMemo → compute values in $() directly, or cache in this._cache keyed on the input:useRef → this is the element. For refs to children, query with this.querySelector or stash in this._child.useContext → a plain module-level variable, or an event bus, or pass props down.
// useMemo equivalent
$({ items }) {
if (this._itemsKey !== items) {
this._itemsKey = items;
this._sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
}
return mx.ul(...this._sorted.map(i => mx.li(i.name)));
}No Framework Lifecycle Hooks
A define()d component has no componentDidMount, no onMount/onDestroy - the $ function is the only entry point, and it runs on render, not on attach or detach. But that's a property of define(), not a limit of the platform. Custom elements have connectedCallback and disconnectedCallback natively, and mx composes with them - so when lifecycle genuinely matters (a subscription, a timer, an observer to tear down on removal) you use the platform's hooks directly. The full story, including the hybrid that gives you both, is on the Lifecycle page.
For the common cases that don't need a real lifecycle, two lighter patterns suffice:
- First-run logic: guard with
this._initialized ||= true inside $. Runs the first time $ is called - good for one-time setup with no teardown. - Wait until in DOM: poll with
requestAnimationFrame checking this.isConnected. Useful for libraries that need layout before init (editors, chart engines) when you don't want to register a custom element. - Real attach/detach cleanup: for anything that must stop when the element is removed - timers, observers,
document/window listeners, event-bus subscriptions - register the tag with customElements.define() and use disconnectedCallback. Element-local handlers and child DOM are reclaimed automatically; those resources are not. See what you do and don't have to clean up.
// Run setup once, wait until mounted - no registration needed
define('code-editor', {
$({ value }) {
if (!this._initialized) {
this._initialized = true;
let init = () => this.isConnected
? setupCodeMirror(this, value)
: requestAnimationFrame(init);
requestAnimationFrame(init);
}
return [];
}
});No Fragments
No <>...</>, no <Fragment>. A component returns either a single mx description or an array of children. Arrays are spread into the host element.
// Return multiple top-level children by returning an array
define('card-body', {
$({ title, body }) {
return [
mx.h3(title),
mx.p(body),
mx.hr()
];
}
});The component element itself (e.g. <card-body>) is the wrapper. If you don't want a wrapper tag, you can't - mx components are custom elements, which means they occupy a real DOM slot. Plain functions don't have this constraint:
// Plain function - no wrapper element
let CardBody = ({ title, body }) => [
mx.h3(title),
mx.p(body),
mx.hr()
];
// Used with spread
mx.div(...CardBody({ title: 'Hi', body: 'Body' }))No Portals
No ReactDOM.createPortal. If you need to render into a different DOM location (modals, toasts, tooltips), just render into it directly:
// Toast container, appended to body once
let toastContainer = null;
function toast(msg) {
if (!toastContainer) {
toastContainer = dom.div({ class: 'toast-container' });
document.body.append(toastContainer);
}
let t = dom.div({ class: 'toast' }, msg);
toastContainer.append(t);
setTimeout(() => t.remove(), 4000);
}No portal abstraction because none is needed. Elements are real; you can append them anywhere.
No Memoization
No React.memo, no PureComponent, no $memo. Components re-render whenever their parent calls $() on them, and the reconciler handles minimal DOM updates via attribute diffing and stamp-based cleanup.
Before caching anything, measure. The reconciler is already so cheap (benchmarks show ~1KB heap retained per 500-operation run in steady state) that component-level memoization is usually pointless. Skip the cache until you have evidence of a real cost.
When a computation really is expensive
Memoize the function, not the component. The cache lives as a property on the function itself - no external variable, no closure wrapper, no generic helper. Functions are objects; treat them as such:
function expensiveFormat(arg) {
let c = expensiveFormat.lookup ||= {};
return c[arg] ??= /* actual expensive work */;
}
// Component stays trivial - no bookkeeping, no keys, no cache invalidation
define('my-list', {
$({ items = [] }) {
return mx.ul(...items.map(i => mx.li(expensiveFormat(i.id))));
}
});The ||= initializes the cache on first call; ??= fills a slot only if it's nullish. Three lines of body, no ceremony. Works for string and number inputs.
For object inputs (where you want reference-identity matching), use a Map - plain objects stringify keys, so cache[item] becomes cache["[object Object]"] and every item collides:
function expensiveFormat(item) {
let c = expensiveFormat.lookup ||= new Map;
if (!c.has(item)) c.set(item, /* expensive work */);
return c.get(item);
}For object inputs where entries should be GC'd when the object is freed, use new WeakMap instead.
Why this beats component-level caching
- Shared across instances: ten
my-list components formatting the same item do the work once, not ten times. - Shared across call sites: the table, the tooltip, the summary panel all hit the same cache.
- No per-component bookkeeping: no
this._key, no this._rendered, no invalidation logic sprinkled through $(). - Survives anything: the cache is a property on the function; it lives as long as the function does.
Caveats worth knowing
- Pure inputs only: if the function depends on anything outside the arg (locale, user settings, time), include it in the key or bust the cache when it changes.
- Reserved keys on plain objects:
lookup['toString'] returns Object.prototype.toString (truthy), so ??= never fires. If inputs are user-supplied strings, use Object.create(null) or a Map as the cache. - Unbounded growth: the cache never forgets. For long-lived apps with many unique inputs, periodically
delete expensiveFormat.lookup or switch to a WeakMap keyed on object inputs.
When component-level caching is the right tool
If the expensive work is specific to one component instance (a per-instance sort order, a scroll-window computation, a layout measurement), cache on this._:
$({ items = [] }) {
// Cache the derived value per instance, keyed on the input reference
if (this._itemsRef !== items) {
this._itemsRef = items;
this._sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
}
return mx.ul(...this._sorted.map(i => mx.li(i.name)));
}Function-level memoization for shared pure work, component-level caching for per-instance derived state. Most apps don't need either.
No Context
No Provider/Consumer, no useContext. Three alternatives in increasing decoupling:
- Prop drilling - pass values down explicitly. Usually fine up to 2-3 levels.
- Module-level variable - declare
let theme = 'dark' at module scope, reference it from any component that needs it. - Event bus - pub/sub for decoupled cross-component communication. See the Architecture page.
// Module-level shared state - simplest option
let auth = { user: null };
define('nav-bar', {
$() {
return auth.user
? mx.span('Hi, ' + auth.user.name)
: mx.a({ href: '/login' }, 'Log in');
}
});No useRef / createRef
this inside $ is the component element itself - that's your "ref." For references to child elements, store them in this._ properties:
define('auto-focus', {
$({ value, focused }) {
this._input ||= dom.input({
'.value': value,
oninput: e => this.$({ value: e.target.value })
});
if (!focused) {
this.$({ focused: true }); // recursive render appends the input to the DOM
this._input.focus(); // now it's mounted, focus works
}
return [this._input];
}
});Why this works
Calling this.$({ focused: true }) inside $ triggers a recursive render. The inner call enters the "else" path (focused is now true), returns [this._input], and the reconciler appends the input to the component element. Control returns to the outer call, where this._input.focus() finds the input attached to the component. One caveat: .focus() only moves focus if the host element is itself in the document. If you create the component with dom() and focus before appending the host, defer the focus (a requestAnimationFrame, or connectedCallback - see Lifecycle). When the host is already mounted, the synchronous call works as shown.
Note the rule: never write this.$state.x = ... directly. Always go through this.$({ ... }) so state flows through the declarative model. Mutating $state directly works most of the time and then mysteriously breaks - it skips the merge and whatever bookkeeping the wrapper might do.
No forwardRef, no refs-as-callbacks, no useImperativeHandle. If you need to expose methods, put them on the definition object (see Methods Beyond $).
No Synthetic Events
Event handlers are set as native DOM properties (element.onclick = fn). No synthetic event pooling, no e.persist(). e is a real MouseEvent/KeyboardEvent/etc. and is safe to keep a reference to.
mx.button({
onclick: e => {
// e is a native MouseEvent - no pooling, no persist() needed
console.log(e.clientX, e.target, e.currentTarget);
setTimeout(() => console.log(e.target), 1000); // still valid
}
}, 'Click')No Built-In Reactivity
No signals, no observables, no ref(), no computed(), no implicit dependency tracking. State updates are explicit: you call this.$({...}) or you re-render a container by calling render() on it.
This is a deliberate choice - explicit updates make dataflow visible and debuggable. For real-time dashboards with 10+ components sharing state, the updateAll() pattern (Architecture page) scales further than signals because there's no dependency graph to maintain.
If you want signals, a 20-line sample implementation is on the State page. It's not part of mx; it's a pattern you can copy.
No SSR Hydration Contract
mx does not have hydration markers (data-reactroot, $$scope, hk=). That's because it doesn't need them - the reconciler walks existing DOM children by tag name and reuses whatever matches. Mismatches are silently patched.
Call this parasitic hydration: mx attaches to whatever HTML is already on the page and takes over from there. There's no client/server contract, no framework-specific markup to emit at build time, no mismatch errors to debug. If the server-rendered tree looks structurally like what render() is about to produce, mx reuses those nodes in place; if it doesn't, the reconciler replaces them and moves on. Server-rendered markup, hand-written HTML, static templates - all work as hydration sources without a handshake.
<!-- Static HTML shipped by server -->
<div id="app">
<h1>Welcome</h1>
<p>Server-rendered content</p>
</div>
<script>
// Client picks up where server left off - no hydration marker needed
document.getElementById('app').render(
mx.h1('Welcome'), // matches existing h1, reused
mx.p('Client-updated content') // matches existing p, text updated
);
</script>See the SSG section for the build-time pattern.
Summary Table
| React / Vue / Svelte | mx equivalent |
|---|
useState | $state + this.$({...}) |
useEffect | Code in $(); first-run guard via this._initialized ||= true |
useMemo / React.memo | Manual caching on this._ properties |
useRef | this is the element; child refs via this.querySelector or this._child |
useContext / Provider | Module-level variable or event bus |
Fragments <>...</> | Return an array from $; plain functions for no-wrapper |
| Portals | dom() + document.body.append |
| Refs / forwardRef | Methods on the definition object (Object.assign mixin) |
onMount / onDestroy | First-run guard, or connectedCallback/disconnectedCallback on a registered custom element (Lifecycle) |
| Signals / reactivity | Explicit $() calls; updateAll() pattern for shared state |
| SSR hydration | Automatic - reconciler reuses matching DOM |
| Synthetic events | Native DOM events |
| JSX | Plain function calls: mx.div(...) |
The philosophy
Every omission is deliberate. mx fits in 790 bytes because it does one thing (reconcile a DOM tree) and trusts the language and platform for everything else. If you find yourself wanting a feature that's not here, the answer is almost always "plain JavaScript does that."