Gotchas
Things that trip people 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 moveWhen 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.
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 0Rule 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 }); // worksmx.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.