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:

// 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:

// 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:

  1. Prop drilling - pass values down explicitly. Usually fine up to 2-3 levels.
  2. Module-level variable - declare let theme = 'dark' at module scope, reference it from any component that needs it.
  3. 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 / Sveltemx equivalent
useState$state + this.$({...})
useEffectCode in $(); first-run guard via this._initialized ||= true
useMemo / React.memoManual caching on this._ properties
useRefthis is the element; child refs via this.querySelector or this._child
useContext / ProviderModule-level variable or event bus
Fragments <>...</>Return an array from $; plain functions for no-wrapper
Portalsdom() + document.body.append
Refs / forwardRefMethods on the definition object (Object.assign mixin)
onMount / onDestroyFirst-run guard, or connectedCallback/disconnectedCallback on a registered custom element (Lifecycle)
Signals / reactivityExplicit $() calls; updateAll() pattern for shared state
SSR hydrationAutomatic - reconciler reuses matching DOM
Synthetic eventsNative DOM events
JSXPlain 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."