Deciding between mx() and dom()
This is the one distinction worth getting right, because every other pattern on this page rests on it. Both build elements from the same tag-proxy syntax. The difference is what you get back, and therefore what you can do next:
mx.div(...) returns a description - a tiny array marked ._=true. It is not in the DOM and isn't an element. render() turns it into (or reconciles it against) real nodes.dom.div(...) returns a real element, created and attribute-applied immediately. You can hold it, append it, query it, call methods on it.
The rule in five words: mx() describes; dom() exists. If you're saying "here's what should be on screen," that's mx(), and it belongs inside a render() or a component's $. If you need the element as a value - to keep a reference, mutate it later, or hand it to code that isn't mx - that's dom().
The default: mx() inside render
Ninety percent of the elements you write are descriptions. Inside a component, inside a view, inside any render() call - reach for mx. You never hold these; the reconciler owns them.
// Inside render() / a component's $: describe the tree with mx().
// These are throwaway descriptions - mx reconciles them against the DOM.
define('user-card', {
$({ user }) {
return mx.div({ class: 'card' },
mx.img({ src: user.avatar }),
mx.h3(user.name),
mx.button({ onclick: () => follow(user.id) }, 'Follow')
);
}
});Reach for dom() when you need the element later
The tell is a sentence with "later" or "then" in it: create it now, then remove it / update it / read it / pass it somewhere. A description can't do any of that - it's not an element yet.
// dom() returns a REAL element you keep and talk to later.
// A toast you'll remove yourself:
let toast = dom.div({ class: 'toast' }, 'Saved');
document.body.append(toast);
setTimeout(() => toast.remove(), 3000); // you hold the node, so you can remove it
// A component you'll push updates into:
let chart = dom('chart-card', { data: series });
panel.append(chart);
chart.$({ data: nextSeries }); // call .$() on the live element later
// A field you read on submit (no re-render needed - the DOM owns the value):
let nameField = dom.input({ name: 'name' });
form.append(nameField);
onSubmit(() => save(nameField.value));Keyed lists: dom() refs you move, not rebuild
The sharpest case. When list items carry state you must preserve across reorders - an open menu, scroll position, a playing animation, focus - keep a persistent dom() element per key in a Map, update each in place, and return the real nodes. render() matches real nodes by identity and physically moves them rather than recreating them:
define('board', {
$({ cards = [] }) {
// Keep one persistent element per id. Reordering then MOVES the same
// nodes - preserving focus, scroll, animation, and internal state -
// instead of rebuilding them from scratch.
let map = this._cards ??= new Map;
for (let [id] of map)
if (!cards.find(c => c.id === id)) map.delete(id); // drop removed
cards.forEach(c => {
let el = map.get(c.id);
if (!el) map.set(c.id, el = dom('board-card')); // create once, keep the ref
el.$({ card: c }); // one update path - new or existing
});
// Returning real nodes: render() matches them by identity and moves them
return cards.map(c => map.get(c.id));
}
});The decision table
| Scenario | Use | Why |
|---|
Markup inside a component's $ | mx() | Throwaway description; reconciler owns it |
Children passed to render() | mx() | Same - descriptions are what render consumes |
A handle you'll call .$() on later | dom() | You need the live element to update it |
A node you'll append/remove yourself | dom() | Toasts, portals, manual mounting |
| Per-item element in a keyed list | dom() | Preserve state; move by identity, don't rebuild |
| A persistent child kept across re-renders | dom() on this._ | Survives the parent's $ re-running |
| Something handed to a non-mx library | dom() | Charts, editors, maps need a real node |
| The app's mount point | existing element | document.getElementById('app').render(...) |
The most common mistake
Reaching for mx() and then trying to grab the element back - mx.div(...) followed by document.querySelector to find what you just "made." If you need the element, you wanted dom() from the start; mx() never created a node for you to find. The flip side is rarer but real: building a whole static page with dom() when you'll never touch those elements again wastes the reconciler - mx() + one render() is leaner.
They compose
A dom() element's children are usually mx() descriptions: dom.div(mx.h3(title), mx.p(body)) (an mx description isn't a plain object, so it's taken as a child - no empty {} needed; pass real attributes as the first arg only when you have them). You hold the outer node; the reconciler manages its contents. And dom('my-component', {...}) on a registered component calls its $ and returns the live element - the bridge between the two worlds. Full API details on the Reference page.
Lists & Conditionals
Rendering lists is just .map(). Conditionals are ternaries or &&. Re-render the container when data changes - mx updates efficiently.
let { ul, li, button, div } = mx;
let items = ["alpha", "beta"];
let host = dom.div();
function view(){
return div(
ul( ...items.map(x => li(x)) ),
button({ onclick(){ items.push("item " + (items.length+1)); host.render(view()); } }, "Add"),
button({ onclick(){ items.pop(); host.render(view()); } }, "Remove")
);
}
host.render(view());
document.body.render(host);Tip
Re-rendering a container is usually simpler than surgically mutating children. mx updates efficiently.
Card + Slots (composition pattern)
Pass children by name using a plain object. No Shadow DOM required. The Card function checks whether kids is an array or an object to support both default children and named slots.
let { div, h4, p, button } = mx;
let Card = (props = {}, kids = []) => {
let slots = Array.isArray(kids) ? { default: kids } : kids;
return div({ class:"card" },
div({ class:"card__header" }, h4(props.title || "Card") ),
div({ class:"card__body" }, ...(slots.default || []) ),
div({ class:"card__footer" }, ...(slots.footer || []) )
);
};
document.body.render(
Card({ title:"Welcome" }, {
default: [ p("Cards compose apps nicely.") ],
footer: [ button("Close") ]
})
);Data Tables
A sortable table component. Sort state lives in $state so clicking column headers toggles sort direction and re-renders.
define('sortable-table', {
$({ data = [], columns = [] }) {
let { table, thead, tbody, tr, th, td, div } = mx;
let sortKey = (this.$state.sortKey ??= columns[0]?.key);
let asc = (this.$state.asc ??= true);
let sorted = [...data].sort((a, b) => {
let val = asc ? 1 : -1;
return a[sortKey] > b[sortKey] ? val : -val;
});
return table(
thead(tr(...columns.map(col =>
th({ onclick: () => this.$({
sortKey: col.key,
asc: sortKey === col.key ? !asc : true
})},
col.label || col.key,
sortKey === col.key ? (asc ? ' ↑' : ' ↓') : ''
)
))),
tbody(...sorted.map(u =>
tr(...columns.map(col => td(u[col.key])))
))
);
}
});
let users = [
{ name: "Alice", age: 25, city: "NYC" },
{ name: "Bob", age: 30, city: "LA" },
{ name: "Charlie", age: 35, city: "Chicago" },
{ name: "Diana", age: 28, city: "Miami" },
{ name: "Ethan", age: 42, city: "Seattle" }
];
document.body.render(
mx.sortableTable({
columns: [
{ key: "name", label: "Name" },
{ key: "age", label: "Age" },
{ key: "city", label: "City" }
],
data: users
})
);Date Picker
A date picker component using $state to track the viewed month and selected date independently. Navigating months preserves the selection.
define('date-picker', {
$({ selected, onSelect }) {
let { div, button, table, thead, tbody, tr, th, td } = mx;
let picked = (this.$state.picked ??= selected || new Date);
let view = (this.$state.view ??= new Date(picked));
let year = view.getFullYear();
let month = view.getMonth();
let daysInMonth = new Date(year, month + 1, 0).getDate();
let firstDay = new Date(year, month, 1).getDay();
let days = [];
for (let i = 0; i < firstDay; i++) days.push("");
for (let d = 1; d <= daysInMonth; d++) days.push(d);
// Highlight only when viewing the picked date's month
let pickedDay = picked.getFullYear() === year
&& picked.getMonth() === month ? picked.getDate() : 0;
return [
div(
button({ onclick: () => {
view.setMonth(month - 1);
this.$({ view });
}}, "‹"),
`${new Date(year, month).toLocaleString('default',
{ month: 'long' })} ${year}`,
button({ onclick: () => {
view.setMonth(month + 1);
this.$({ view });
}}, "›")
),
table(
thead(tr(...["Su","Mo","Tu","We","Th","Fr","Sa"]
.map(d => th(d)))),
tbody(...Array.from(
{ length: Math.ceil(days.length / 7) }, (_, w) =>
tr(...days.slice(w * 7, (w + 1) * 7).map(day =>
td({
class: day === pickedDay ? "selected" : "",
onclick: () => {
if (!day) return;
this.$({
picked: new Date(year, month, day),
view
});
onSelect?.(new Date(year, month, day));
}
}, day || "")
))
))
)
];
}
});
document.body.render(mx.datePicker());Todo Example
A classic Todo app in mx. State lives in local variables; the entire view re-renders on every change. mx handles minimal DOM updates under the hood.
let { div, h3, input, button, ul, li, span } = mx;
let todos = [];
let text = "";
function TodoApp(){
return div({ class:"todo" },
h3("Todos"),
div(
input({ ".value": text, oninput: e => {
text = e.target.value;
todoHost.render(TodoApp());
}}),
button({ onclick: () => {
if (text.trim()) {
todos.push({ text, done: false });
text = "";
todoHost.render(TodoApp());
}
} }, "Add")
),
ul( ...todos.map((t, i) =>
li({ class: t.done ? "done" : "" },
input({ type:"checkbox", ".checked": t.done, onchange: () => {
t.done = !t.done;
todoHost.render(TodoApp());
}}),
span(t.text),
button({ onclick: () => {
todos.splice(i, 1);
todoHost.render(TodoApp());
} }, "×")
)
))
);
}
let todoHost = dom.div();
todoHost.render(TodoApp());
document.body.render(todoHost);Drag & Drop Reordering
HTML5 drag events work naturally with mx. A reorderable list component tracks the dragged index in $state and splices on drop.
define('drag-list', {
$({ items = [], onReorder }) {
let { div, span } = mx;
return items.map((item, i) =>
div({
class: 'drag-item',
draggable: 'true',
ondragstart: () => { this.$state.dragging = i; },
ondragover: e => e.preventDefault(),
ondrop: () => {
let arr = [...items];
let [moved] = arr.splice(this.$state.dragging, 1);
arr.splice(i, 0, moved);
this.$({ items: arr });
onReorder?.(arr);
}
},
span({ class: 'handle' }, '⋮⋮'),
span(item)
)
);
}
});
document.body.render(
mx.dragList({
items: ['Learn mx', 'Build components', 'Ship to prod', 'Profit']
})
);Tip
Use ondragstart to store the source index, ondragover with e.preventDefault() to allow dropping, and ondrop to splice the array and re-render.
File Drop Zone
A drop zone component combines drag events with a hidden file input. Events are assigned directly on this so re-renders overwrite the same property instead of stacking listeners.
define('drop-zone', {
$({ ondrop, accept }) {
let { div, input, span } = mx;
let handleFiles = files => ondrop?.(Array.from(files));
// Direct assignment - safe on re-render (overwrites, never stacks)
this.ondragover = e => {
e.preventDefault();
this.classList.add('dragging');
};
this.ondragleave = e => {
if (e.target === this) this.classList.remove('dragging');
};
this.ondrop = e => {
e.preventDefault();
this.classList.remove('dragging');
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
};
return div({ class: 'drop-area',
onclick: () => this.querySelector('input').click()
},
span({ class: 'drop-text' }, 'Drop files here'),
span({ class: 'drop-hint' }, 'or click to select'),
input({ type:'file', multiple:true, accept,
style: 'display:none',
onchange(e) {
if (e.target.files.length) {
handleFiles(e.target.files);
e.target.value = '';
}
}
})
);
}
});
document.body.render(
mx.dropZone({
accept: 'image/*',
ondrop(files) { alert(files.map(f => f.name).join(', ')); }
})
);Key pattern
Drag events are assigned directly on this (this.ondragover, this.ondrop, etc.) because they need to be on the component element itself. Direct assignment is safe on re-render - it overwrites the previous handler instead of stacking listeners. The visual content is returned as mx children.
Progress Bar (computed attrs)
Components can compute derived values from props and set attributes on themselves via this.$attrs(). This progress bar calculates completion percentage, urgency level, and sets attributes the CSS can target:
define('goal-progress', {
$({ pledged = 0, goal = 1000, deadline }) {
let pct = Math.min(100, Math.round(pledged / goal * 100));
let days = deadline
? Math.ceil((new Date(deadline) - Date.now()) / 86400000)
: null;
// Set attributes for CSS styling
this.$attrs({
completed: pct >= 100 || null,
urgency: days !== null && days <= 3 ? 'high'
: days !== null && days <= 7 ? 'medium'
: null
});
return [
mx.div({ class: 'bar' },
mx.div({ class: 'fill', style: `width:${pct}%` })
),
mx.div({ class: 'stats' },
mx.div(mx.strong('$' + pledged.toLocaleString()),
' of $' + goal.toLocaleString()),
days !== null
? mx.div(mx.strong(days), ' days to go')
: null,
mx.div(mx.strong(pct + '%'), ' funded')
)
];
}
});
document.body.render(
mx.goalProgress({
pledged: 30000, goal: 50000,
deadline: '2026-04-01'
})
);$attrs for CSS hooks
this.$attrs({ urgency: "high" }) sets urgency="high" on the element. Your CSS can then target goal-progress[urgency="high"] for styling. Passing null or false removes the attribute. This is cleaner than toggling class names manually.