Gotchas

Things that trip developers up when learning mx.js.

Positional Rendering

mx reconciles children left-to-right by tag name, not by key. If you reorder a list, the DOM nodes stay in place and their content updates - but user-entered state (typed input values, checkbox ticks) stays at the old position.

// ✗ User input stays at position 0 after sort
container.render(...items.map(i => mx.div(
    mx.span(i.name),
    mx.input({ placeholder: 'notes...' })  // ← this input doesn't move
)));

// ✓ Use dom() + Map for identity-stable rows
let rows = new Map;
for (let i of items) {
    let el = rows.get(i.id) ?? rows.set(i.id, dom.div()).get(i.id);
    el.render(mx.span(i.name), mx.input({ placeholder: 'notes...' }));
}
container.render(...items.map(i => rows.get(i.id)));  // nodes physically move

When render encounters a real DOM node, it matches by identity (===), not tag name. The node moves to the correct position and all internal state travels with it.

Positional mx() in Front of an Identity Node

The two matching rules - mx() descriptions reconcile positionally by tag name, real dom() nodes match by identity - collide when you interleave them in one render(). An mx() node placed in front of a dom() node of the same tag is processed first at that position, so it reconciles into the identity node and consumes it:

let row = dom.div(dom.input());   // a real node you hold a handle to
root.render(row);                  // <div><input></div>

// ✗ the header is also a <div>, sitting where row is:
root.render(mx.div('Header'), row);
// mx.div reconciles INTO row -> row becomes <div>Header</div>, its <input> destroyed.
// Result: ONE child; the input and any typed state are silently gone.

Give the interleaved node a distinct tag

A tag that differs from the identity node sidesteps the collision: the mismatch makes mx insert a fresh node instead of reusing the identity one. A different HTML tag, a custom component tag, or making the sibling a dom() node all work:

root.render(mx.h2('Header'), row);    // ✓ distinct tag -> [h2, row], input kept
root.render(mx.myHeader(), row);      // ✓ a component has its own tag, never collides
root.render(dom.div('Header'), row);  // ✓ dom() header matches by identity too

Or stay in one mode: an all-dom() list reordered by identity, or an all-mx() tree reconciled positionally. The hazard lives only in the interleaved-same-tag seam.

State Accumulation

$state merges via Object.assign. Keys you don't pass persist:

// Render 1: set type
el.$({ type: 'text-field', content: [] });

// Render 2: change content, forget to clear type
el.$({ content: ['tag1', 'tag2'] });

// ✗ $state is now { type: 'text-field', content: ['tag1', 'tag2'] }
// The stale 'type' causes a text field AND tags to render

// ✓ Always send complete state when switching modes
el.$({ type: null, content: ['tag1', 'tag2'] });

The destructuring defaults in $({ type = null }) document what "neutral" means for each prop. Use those values when resetting.

Event Listener Stacking

addEventListener with a new closure inside $() adds another listener on every re-render:

// ✗ 10 renders = 10 listeners
$() {
    this.addEventListener('click', () => doStuff());
}

// ✓ Property assignment overwrites safely
$() {
    this.onclick = () => doStuff();
}

// ✓ Or use a stable reference (addEventListener is idempotent for same fn)
let handler = () => doStuff();
$() {
    this.addEventListener('click', handler);
}

The stamp system auto-cleans handlers set via render() attrs (onclick: fn), but addEventListener on this is outside that system.

Mutating Input Arrays

// ✗ Sorts the parent's array - side effect
$({ items = [] }) {
    items.sort((a, b) => a.name.localeCompare(b.name));
}

// ✓ Copy first
$({ items = [] }) {
    let sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
}

Props point to the parent's data. Mutating them changes state outside the component.

Interval Stacking

Every $() call runs your function again. If you set an interval without clearing the previous one, they stack:

// ✗ New interval every re-render
$({ isPlaying = false }) {
    if (isPlaying) {
        this._interval = setInterval(() => tick(), 1000);
    }
}

// ✓ Always clear first
$({ isPlaying = false }) {
    clearInterval(this._interval);
    if (isPlaying) {
        this._interval = setInterval(() => tick(), 1000);
    }
}

clearInterval at the top of $(), before any conditional logic.

Numbers Leak Into Render

render() treats numbers as text nodes - it creates a text node with that number's string value. This means 0 from .length doesn't get skipped like false or null would:

// ✗ When items is empty, renders a literal "0" text node
container.render(
    items.length && mx.div('Has items')
);

// ✓ Coerce to boolean - false is skipped by render()
container.render(
    !!items.length && mx.div('Has items')
);

This applies to any numeric value on the left side of &&. Booleans (false), null, and undefined are skipped by render(). Numbers are not - they're valid content.

// Same issue with any number
count && mx.span('Count: ' + count)     // ✗ renders "0" when count is 0
!!count && mx.span('Count: ' + count)   // ✓ skipped when count is 0

Rule of thumb: if the left side of && can be a number, prefix with !!.

mx() Where dom() is Needed

// ✗ Can't call .$() on an mx description
let picker = mx.datePicker({ value: today });
picker.$({ value: tomorrow });  // TypeError: picker.$ is not a function

// ✓ Use dom for elements you need to update later
let picker = dom.datePicker({ value: today });
picker.$({ value: tomorrow });  // works

mx.tag() returns an array. dom.tag() returns a real element. If you need to hold a reference and call .$() on it, use dom.tag().

onClick vs onclick

// ✗ React convention - won't fire in mx
mx.button({ onClick: handler })

// ✓ Lowercase - standard DOM event names
mx.button({ onclick: handler })

mx uses native DOM event property names. Always lowercase.

render() Removes Excess Children

render() removes DOM children that have no corresponding node in the new render call:

// Render 3 items
container.render(mx.div('A'), mx.div('B'), mx.div('C'));

// Render 2 items - 'C' is removed from the DOM
container.render(mx.div('A'), mx.div('B'));

This is by design - the rendered children are the source of truth. If you need to preserve extra children, append them separately (not through render()).

Global getElementById in Components

// ✗ Fragile - breaks if two instances exist
$() {
    return mx.input({ id: 'search' });
}
// Later: document.getElementById('search')  // which one?

// ✓ Scope queries to the component
$() {
    return mx.input({ class: 'search' });
}
// Later: this.querySelector('.search')

Global IDs collide when multiple instances of a component exist. Query within the component's own DOM tree.

dom(Component) Without Attrs Skips $()

dom.myComp(...) returns an element immediately. For a defined component (one with a $ function), the attrs path is what triggers the first $() call. Calling dom.myComp() with no attrs falls to the "child" branch and skips initialization:

// ✗ $() never runs. Element exists but is empty.
let picker = dom.datePicker();

// ✓ Pass an empty attrs object to trigger $()
let picker = dom.datePicker({});

// ✓ Or use mx() inside render() - mx path always calls $()
container.render(mx.datePicker());

The asymmetry exists because the arg slot is overloaded: a plain object is attrs, anything else is a child. With no arg at all, dom() has no way to know you meant "attrs = {}" vs "no attrs + no children," and picks the latter.

Arrays as Children Must Be Spread

render() and mx() classify each child: an mx description (array with ._), a DOM node, a string, a number, null/false. A plain array from .map() matches none of those - it falls to the text-node branch and gets stringified:

let items = ['a', 'b', 'c'];

// ✗ Renders literal text: "a,b,c"
mx.ul(items.map(i => mx.li(i)));

// ✓ Spread so each li is a direct child
mx.ul(...items.map(i => mx.li(i)));

Asymmetric with $ return values: returning an array from $ is fine because the wrapper spreads for you. But anywhere else - including inside mx.*() call args - you have to spread manually.

Rule of thumb

Anywhere you're about to pass .map() output as a child argument, prefix with ....

Why ._ Marks an mx Description

render() has to distinguish three things in its argument list: an mx description (mx.div(...)), a real DOM node, and anything else (text content, nullish). It picks between them with a single property check: value._.

// Inside render(), simplified:
if (value._)             { /* mx description: [tag, attrs, kids] */ }
else if (value.nodeType) { /* a real DOM node */ }
else                     { /* text content, or skip if null/false */ }

Why _ specifically? Three reasons, and they're all just JavaScript:

The collision surface

If you pass a plain object whose _ field is truthy as a child, render() destructures it as [tag, attrs, kids] and either crashes or mis-renders. This is rare in practice - data fetched from APIs typically uses semantic keys - but worth knowing if you build render arguments from foreign data:

// ✗ This object collides with the descriptor marker
let data = { _: 'metadata', name: 'Alice' };
mx.div(data);                   // render thinks it's an mx description

// ✓ Stringify, project the field, or wrap explicitly
mx.div(JSON.stringify(data));
mx.div(mx.span(data.name));

Reserved Component and Tag Names

The internal tag-name cache and component registry are plain objects. That means they inherit Object.prototype methods. If you name a component - or call mx.* with a tag name - that collides with one of those methods, you get broken output:

// ✗ Forbidden - all produce broken descriptions or crashes
mx.toString()             // returns Object.prototype.toString as the tag
mx.valueOf()
mx.constructor()
mx.hasOwnProperty()
mx.__proto__

define('toString', {...}) // silently shadowed in createElement

// ✓ Use any name that doesn't collide with Object.prototype
mx.myButton()
define('my-button', {...})

Reserved names to avoid: constructor, hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, toString, valueOf, __defineGetter__, __defineSetter__, __lookupGetter__, __lookupSetter__, __proto__.

Failure mode

Worst case is InvalidCharacterError at createElement time - the rendered component fails to mount. Not exploitable, but a loud, confusing crash. Kept as a known hazard to preserve the 790B budget over Object.create(null).

PascalCase Produces a Leading Hyphen

The camelCase-to-kebab-case conversion uses /[A-Z]/g with no anchor. A leading capital produces a leading hyphen, which is an invalid custom element name:

// ✗ PascalCase → tag "-my-widget" (invalid)
mx.MyWidget()     // <-my-widget>
dom.DatePicker()  // <-date-picker>

// ✓ camelCase → tag "my-widget"
mx.myWidget()     // <my-widget>
dom.datePicker()  // <date-picker>

// ✓ Or use the string form directly
mx('my-widget')
dom('date-picker')

define() lowercases the name but does not kebab-case it, so there's a second trap:

// ✗ Stored as 'mywidget'; mx.myWidget() looks up 'my-widget'. Mismatch.
define('myWidget', { $() {...} });

// ✓ Always use kebab-case in define()
define('my-widget', { $() {...} });

Why camelCase, not PascalCase?

JavaScript's informal convention reserves PascalCase for things you call with new (constructors, classes) and camelCase for everything else (functions, methods, variables, fields). mx.myWidget is a function call, not a constructor invocation - it returns an array, not a fresh instance via new. So the call site reads as camelCase by convention.

If you want really to use PascalCase identifiers - say, you're mirroring a JSX-style component naming - declare a plain function instead: let MyWidget = (props, kids) => mx.div(...). Functions are callable identifiers; the casing convention is yours to break. define()-components map to DOM tags, and DOM tags are kebab-case, which is what camelCase converts to cleanly.

Parent Re-Passing Props Overwrites $state

The component wrapper does Object.assign(this.$state, props) before your $ function runs. If a parent re-renders and passes the same key again, your locally updated state is overwritten:

define('counter', {
    $({ count = 0 }) {
        return [
            mx.button({ onclick: () => this.$({ count: count + 1 }) }, '+'),
            mx.span(count)
        ];
    }
});

// Scenario:
// 1. Parent: el.$({ count: 10 })  → $state.count = 10, rendered as 10
// 2. User clicks + 5 times        → $state.count = 15, rendered as 15
// 3. Parent re-renders same prop: el.$({ count: 10 })
//    → Object.assign overwrites $state.count back to 10
//    → user's clicks are lost

Usually this is what you want

A controlled component should accept parent-passed state. A form field whose value is owned by the parent should be overwritten when the parent re-passes. This is the declarative model doing its job.

If you need a value that survives parent re-passes, use a different key - take the prop as a seed and store local state separately:

define('counter', {
    $({ initial = 0 }) {
        this._count ??= initial;   // seeded once from the prop, never clobbered
        return [
            mx.button({ onclick: () => { this._count--; this.$({}); } }, '−'),
            mx.span(this._count),
            mx.button({ onclick: () => { this._count++; this.$({}); } }, '+')
        ];
    }
});

This pattern needs this.$({}) to trigger a re-render - which is normally a code smell. The cleaner answer is usually to let the parent own the state and pass it down; components that truly need local-state-immune-to-parent are surprisingly rare.

Destructured State Is a Render-Time Snapshot

Inside $(), destructured values are snapshots from that render pass. They do not update when $state changes behind the scenes:

// The ✗ pattern: count is captured at render time
define('counter', {
    $({ count = 0 }) {
        return [
            mx.button({ onclick: () => this.$({ count: count - 1 }) }, '−'),
            mx.span(count)
        ];
    }
});

Why single clicks usually work. On a single synchronous click, the handler fires, this.$() re-renders, and a brand-new handler (closing over the fresh value) replaces the old one in the DOM before the next click. The bug is invisible until you do something slightly more interesting.

Three ways the closure goes stale

1. Multiple state updates in one handler

// ✗ Both calls read count = 5. Result is 4, not 3.
onclick: () => {
    this.$({ count: count - 1 });
    this.$({ count: count - 1 });
}

2. Async gaps

// ✗ Timeout fires with the render-time value, not the current one
onclick: () => setTimeout(() => this.$({ count: count - 1 }), 100)

3. Child-to-parent callbacks

// ✗ Parent sends count: 10, child clicks +, but handler
// still closes over the old count from an earlier render
// when the parent re-renders without recreating the child
onclick: () => this.$({ count: count - step })

Solution 1: Live read from this.$state (safest default)

Read from the live object at execution time. The closure captures this (which never changes) rather than a scalar value:

// ✓ Always sees current state, even across async gaps
onclick: () => this.$({ count: this.$state.count - 1 })

For handlers that survive many renders, cache them with ??= so the renderer's stamp diff (g === f[b]) skips re-attaching after the first mount:

define('counter', {
    $() {
        let n = this.$state.count ??= 0;
        return [
            mx.button({
                onclick: this.dec ??= () => this.$({ count: this.$state.count - 1 })
            }, '−'),
            mx.span(n),
            mx.button({
                onclick: this.inc ??= () => this.$({ count: this.$state.count + 1 })
            }, '+')
        ];
    }
});

Solution 2: Inline function with local computation

If you prefer destructuring, you can still use it safely as long as the handler is recreated every render (no caching) and you do not read the closed-over value after an async gap:

define('counter', {
    $({ count = 0 }) {
        let me = this;
        return [
            mx.button({
                onclick() { me.$({ count: --count }); }
            }, '−'),
            mx.span(count),
            mx.button({
                onclick() { me.$({ count: ++count }); }
            }, '+')
        ];
    }
});

Why this works: the handler is recreated every render, so it always closes over the latest value. The parent-re-pass scenario is fine because a re-render creates a new closure with the fresh value.

Why this is less ideal: the renderer re-assigns onclick to the DOM node on every state change. V8 handles this fine in practice, but it is strictly more work than the cached-handler approach. Also, this inside a function() event handler is the DOM node (the button), not the component. You must capture the component in a local variable (let me = this) or use an arrow function.

Solution 3: Batch multiple mutations into one this.$() call

If you are mutating the same value several times before re-rendering, compute locally and call this.$() once:

// ✗ Two renders; second reads stale closure
onclick: () => {
    this.$({ count: count - 1 });
    this.$({ count: count - 1 });
}

// ✓ One render; local variable is authoritative for this event
onclick() {
    let c = count;
    c -= 2;
    me.$({ count: c });
}

This is faster (single render pass) and sidesteps the stale-read problem entirely for the duration of the handler.

Summary: which pattern to use when

  • Live read + cached handler - best for stateful components with many re-renders. Most efficient, always correct.
  • Destructured + inline handler - fine for simple components with minimal re-renders where re-attachment overhead is negligible. Avoid async gaps.
  • Batch updates - always do this when you need to change the same key multiple times before re-rendering.

The common mistake

Most developers write the first pattern (() => this.$({ count: count - 1 })) because it looks clean and works in the toy example. It only breaks later, when someone adds a debounce, a confirmation dialog, or a second increment. Start with the live-read pattern and you never have to debug it.

React-Style Attribute Names Don't Work

mx uses native DOM names. Three common React conventions fail silently:

ReactDOM / mxFailure mode
onClickonclickHandler never fires (property name mismatch)
classNameclasssetAttribute creates a nonstandard "className" attribute; CSS selectors don't match
htmlForforLabel-input association broken
tabIndextabindexIgnored by browser

Same rule for every attribute and event: lowercase, no camelCase.

Boolean Attributes Use HTML, Not JS, Truthiness

mx's attribute setter only treats three values as removal triggers: null, undefined, and literal false. Everything else - including 0, NaN, empty strings, and the string "false" - calls setAttribute. This is intentional, and it matches how HTML works, not how JS truthiness works.

// All of these set the attribute. The element ends up "disabled":
mx.button({ disabled: 0 })           // setAttribute('disabled', '0')
mx.button({ disabled: NaN })         // setAttribute('disabled', 'NaN')
mx.button({ disabled: '' })          // setAttribute('disabled', '')
mx.button({ disabled: 'no' })        // setAttribute('disabled', 'no')
mx.button({ disabled: 'false' })     // setAttribute('disabled', 'false')

// Only these remove the attribute:
mx.button({ disabled: false })       // removeAttribute('disabled')
mx.button({ disabled: null })        // removeAttribute('disabled')
mx.button({ disabled: undefined })   // removeAttribute('disabled')

Why?

HTML boolean attributes are presence-based, not value-based. <button disabled>, <button disabled="">, and <button disabled="false"> all render the same disabled button. The browser doesn't parse the value - the attribute either exists or it doesn't.

mx mirrors the platform. Treating 0 or "" as "remove" would silently corrupt non-boolean attributes that actually need those values: padding: 0, flex: 0, value: '', placeholder: '', tabindex: 0 are all real and meaningful. Only the JS nullish trio plus literal false is unambiguous as "the attribute is absent".

To toggle a boolean attribute by a JS condition

Coerce to a strict boolean before passing it through:

mx.button({ disabled: !!errorCount })       // ✓ 0 → false → removed
mx.button({ disabled: state === 'sending' }) // ✓ boolean output
mx.button({ disabled: missing || false })    // ✓ default to false
mx.button({ disabled: cond ? true : null })  // ✓ explicit two-state

Or use the literal true value, which mx serializes to the string "true" on every path (mx, dom, $attrs): disabled: truedisabled="true". The attribute is present, so boolean attributes activate; enumerated attributes like aria-expanded get the correct "true"/"false" value.

No Built-in class/style Helpers

Other frameworks accept rich values for class and style: arrays, objects, conditional maps. mx doesn't. Both attributes go through setAttribute, and setAttribute coerces with "" + value - so non-strings stringify in the obvious, usually-wrong way:

// Each of these stringifies in the obvious way - none of them do what you want
mx.div({ class: ['a', 'b'] })                  // class="a,b"     (one weird class named "a,b")
mx.div({ class: { foo: true, bar: false } })   // class="[object Object]"
mx.div({ style: { color: 'red' } })            // style="[object Object]" → invalid CSS, dropped silently

// What mx wants you to write:
mx.div({ class: 'a b' })
mx.div({ class: cond ? 'foo active' : 'foo' })
mx.div({ style: `color:${color};width:${w}px` })

Why no helpers?

mx is 790 bytes brotli. A clsx-style class composer would add ~150B - meaningful at this scale. The bigger reason: components decide their own classes inside $. The pattern that motivates class={a:true,b:isOn} (per-render conditional class flipping at the call site) is rare in practice; most apps swap one or two states (active, disabled, loading) that destructure cleanly into a template literal.

If you really do need object-style class composition, four lines of plain JS does it. Keep it in your own utils:

// Drop into your own utils.js
let cls = (...args) => args.flat().filter(Boolean).map(a =>
    a.constructor === Object
        ? Object.entries(a).filter(([,v]) => v).map(([k]) => k).join(' ')
        : a
).join(' ');

mx.div({ class: cls('btn', size, { active, disabled }) });

The lesson generalizes: mx ships the smallest set of primitives that compose. Things you'd only use in 5% of components belong in your project, not in the framework.

Style: prefer .style inside $

If you're already inside a component, skip the attribute path entirely. Direct property writes are faster than setAttribute and accept structured input:

$({ color, hidden }) {
    this.style.color = color;             // direct property
    this.style.cssText = 'padding:8px';   // batch many at once
    if (hidden) this.style.display = 'none';
    return [...];
}

Null-Prototype Objects Aren't Attrs

mx detects attrs via attrs.constructor === Object. Objects created with Object.create(null) have no prototype chain, so .constructor is undefined - the check fails and they fall to the child slot:

let attrs = Object.create(null);
attrs.class = 'box';

// ✗ attrs is treated as a child, stringified to "[object Object]"
mx.div(attrs, 'content')

// ✓ Plain object literals work
mx.div({ class: 'box' }, 'content')

Rare in practice, but worth knowing if you're building attrs programmatically.

Returning a DOM Node from $

The component wrapper dispatches the return value of $ through value._ ? render(value) : render(...value). A DOM node has no ._ property, so it falls into the spread branch. render(...node) fails because DOM nodes are not iterable:

// ✗ TypeError: HTMLElement is not iterable
define('media', {
    $({ url }) {
        return makeIframeFor(url);    // returns a real <iframe> element
    }
});

// ✓ Wrap in an array - render(...[node]) is fine
define('media', {
    $({ url }) {
        return [makeIframeFor(url)];
    }
});

// ✓ Or wrap in an mx description so ._ takes the single-arg branch
define('media', {
    $({ url }) {
        return mx.div(makeIframeFor(url));
    }
});

In practice this only matters when integrating third-party widgets - CodeMirror, charts, embeds - that hand you a real DOM element you want to mount as the component's sole child. Always wrap. The cost is one bracket pair.

Why the wrapper works this way

Returning a single mx description (return mx.div(...)) is the common case, and the wrapper handles it without forcing you to write return [mx.div(...)] every time. The ._ check distinguishes "single description" from "array of children" without an Array.isArray call. DOM nodes weren't in the design space because the component itself is a DOM node - returning another one is the unusual case.

The Tag-Name Cache Is Permanent

mx memoizes its camelCase-to-kebab-case conversion in a plain module-level object:

let tagCacheObject = {},
    toTag = tagAlias => tagCacheObject[tagAlias] ||= tagAlias.replace(/[A-Z]/g, '-$&').toLowerCase();

Every unique tag name you ever pass to mx.* or dom.* lives in this object until the page closes. There is no eviction. This is by design.

Why this is fine

A real app uses some bounded number of tag names: div, span, button, input, plus your custom components. Maybe 60 unique strings, total. The cache occupies a few hundred bytes for the lifetime of the tab. The savings come from skipping the regex on every call - a meaningful win at 60fps.

This is just how JS works: any object reachable from a closure (or the global scope) is retained until that closure is unreachable. The cache is reachable from the mx Proxy, which is global, which lives until the page unloads. The same is true of every cache, registry, or memo your app builds.

When it would matter

If you generate tag names dynamically from unbounded input, the cache will grow without bound:

// ✗ One new entry per id, forever
items.forEach(item => mx(`row-${item.id}`, ...));

// ✓ Use a fixed tag, distinguish by attribute
items.forEach(item => mx('a-row', { 'data-id': item.id }, ...));

This is rare - most apps use a fixed set of tag names - but it's the kind of leak that's invisible until your tab has been open for a week. The general lesson: any cache keyed on user data without an eviction policy is a memory leak waiting for the right input.

Namespaced SVG Attributes

mx creates SVG elements via createElementNS (mx.svg is always created in the SVG namespace; mx.path, mx.circle etc. inherit it only when nested inside an svg host, since the namespace is taken from the host element - a top-level mx.path or any dom.path/dom.circle lands in the HTML namespace and renders invisibly). But it sets attributes via plain setAttribute, not setAttributeNS. For attributes that require a namespace prefix - xlink:href, xml:space, xml:lang - the attribute lands in the wrong namespace and the browser silently ignores it.

// ✗ Older SVG that uses xlink: this no-ops
mx.use({ 'xlink:href': '#icon-star' })

// ✓ Modern SVG (SVG 2, supported in every browser since ~2017)
mx.use({ href: '#icon-star' })

Why no support?

SVG 2 deprecated the xlink: namespace in 2017. Every major browser supports unprefixed href on SVG elements: Chrome 49+, Firefox 51+, Safari 12+, Edge 79+. If you're not targeting IE11 or pre-2018 Safari, you don't need xlink:.

mx omits the namespace path because the byte cost (~80B compressed for a parallel setAttributeNS branch) buys compatibility with software that no longer ships security patches. Worth checking whether your real constraint is browser support or copy-pasted-from-a-2014-tutorial markup. In most cases the answer is the markup, and the fix is updating the markup, not the framework.

If you really do need it

Drop into the underlying DOM API directly inside $:

define('legacy-svg-icon', {
    $({ iconId }) {
        let use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
        use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + iconId);
        return mx.svg(use);
    }
});

The two-line escape hatch is always available. mx doesn't hide the platform - it just declines to ship a wrapper for an attribute path the platform itself has retired.

this.$({}) Is a Code Smell

Calling this.$({}) with an empty object merges nothing into $state and then re-invokes $ with unchanged state. If you find yourself writing this, it almost always means the component is relying on this._x mutations as side effects and using $({}) to flush a render:

// ✗ Hidden data flow - what actually changed?
$() {
    this._items.push(newItem);    // mutation nobody sees
    this.$({});                    // "please re-render"
}

// ✓ Make the change part of state
$({ items = [] }) {
    return mx.button({ onclick: () =>
        this.$({ items: [...items, newItem] })
    }, 'Add');
}

If the re-render is triggered by a child callback, have the child call onChange/onSelect/etc. and let the parent re-render with meaningful state. See $state vs this._property for the full rule.