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 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.

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.