define() Components
For reusable components, use define(tagName, { $() {...} }) to create custom elements. The $ function receives props and returns children to render.
What define() provides
this - the actual DOM elementthis.$attrs(attrs) - efficiently set multiple attributesthis.$state - component state objectthis.render(...children) - update children
Here's a simple row component:
define('a-row', {
$({ columns, data }) {
let { div } = mx;
return columns.map(col => {
let value = col.fn?.(data[col.key], data) || data[col.key];
return div({ class: 'table-cell' }, value);
});
}
});
// Use it
let columns = [
{ key: 'name', label: 'Name' },
{ key: 'age', label: 'Age' }
];
let user = { name: 'Alice', age: 25 };
document.body.render(
mx.aRow( { columns, data: user })
);Components can compose other components:
define('a-table', {
$({ columns = [], data = [], rowHeight = 48, static = false }) {
let { div } = mx;
let header = div({ class: 'table-header' },
...columns.map(col => div({ class: 'table-cell' }, col.label || col.key))
);
let rows = data.map((item, index) =>
mx.aRow({ columns, data: item, index })
);
if (static) {
return [header].concat(rows);
} else {
return [header, mx.virtualContainer({ height: 400, rowHeight, rows })];
}
}
});
// Use it
document.body.render(
mx.aTable( {
columns: [{ key: 'name' }, { key: 'age' }],
data: [{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }]
})
);// 1000-row virtual-scrolling table built from define() components
let columns = [
{ key: 'name', label: 'Name' },
{ key: 'age', label: 'Age' },
{ key: 'city', label: 'City' }
];
let data = [];
for (let i = 0; i < 1000; i++) {
data.push({
name: 'Person ' + (i + 1),
age: 20 + (i % 50),
city: ['New York','Los Angeles','Chicago','Houston','Phoenix'][i % 5]
});
}
document.body.render(
div({ style: 'border:1px solid var(--dim); border-radius:2px; overflow:auto;' },
mx.aTable( { columns, data })
)
);Best Practice
Use define() for reusable components that you'll use across your app. This creates proper custom elements with all the benefits of DOM encapsulation.
Functions (local fragments)
Functions are useful for local, view-specific fragments that you don't need to reuse. For most reusable components, prefer define().
let { div, span } = mx;
// Local helper for this view only
let StatusBadge = (status) => {
let color = status === 'active' ? 'green' : 'gray';
return span({ class: 'badge', style: `color: ${color}` }, status);
};
// Use inline in your view
document.body.render(
div({ class: 'user-card' },
div('Username'),
StatusBadge('active')
)
);When to use functions vs define()
- Functions: Local helpers, view-specific fragments, one-off compositions
- define(): Reusable components, components needing state/methods, shared UI elements
Components Are Boundaries
define() creates a hard boundary. Inside the boundary the component owns its DOM, its state, and what it renders. Outside the boundary you talk to it through one channel: props. Children passed positionally to a component instance are silently dropped:
define('my-card', {
$({ title }) {
return mx.div({ class: 'card' },
mx.h2(title)
// ... where would the children go?
);
}
});
// ✗ mx.p('body text') is dropped - the component never sees it
mx.myCard({ title: 'Hello' }, mx.p('body text'))
// The component's $ receives only { title: 'Hello' }.
// Positional children are intentionally not forwarded.This is by design. The render loop knows about two kinds of children: things to mount into a plain element's tree, and props to hand to a component's $. Positional children fit the first model and not the second:
Why positional children don't work for components
Imagine a card that wants header, body, and footer slots. With positional children you'd need:
mx.myCard({...}, header, body, footer)
mx.myCard({...}, , body, footer) // illegal - JS doesn't allow array holes
mx.myCard({...}, undefined, body, footer) // works, but uglier than propsPosition 0 is "header." How do you skip it cleanly? You can't, without a sentinel value or a wrapper object. And once you're passing a wrapper object, you're back to props - just spelled awkwardly. Named slots are positional children's natural endpoint.
There's a render-side problem too: positional children would have to be reconciled differently per component (this card's position 0 is a header, that menu's position 0 is a trigger). Props let each component define its own slot semantics without baking a special syntax into the framework.
The pattern: pass slots as named props. They can hold anything you'd normally render - mx descriptions, DOM nodes, arrays, strings - and the component decides where each one goes:
define('my-card', {
$({ header, body, footer }) {
return mx.div({ class: 'card' },
mx.div({ class: 'card__header' }, header),
mx.div({ class: 'card__body' }, body),
mx.div({ class: 'card__footer' }, footer)
);
}
});
mx.myCard({
header: mx.h2('Title'),
body: mx.p('Body text'),
footer: mx.button('OK')
})Plain functions are different
The Named Slots section below shows a plain-function component that does receive positional children, because it's just a function call - the args go where you put them. Functions are not boundaries; they compose by call. define() components are boundaries; they compose by props. Pick the tier that matches what you need.
Named Slots (cheap & cheerful)
Pass children by name using a plain object. No Shadow DOM required. The same idea works two ways - as a function (compose at the call site) or inside a define() component (compose at the boundary).
let { div, h3, p, button } = mx;
let Card = (props = {}, kids = []) => {
let slots = Array.isArray(kids) ? { default: kids } : kids;
return div({ class:"card" },
div({ class:"card__header" }, ...(slots.header || [])),
div({ class:"card__body" }, ...(slots.default || []) ),
div({ class:"card__footer" }, ...(slots.footer || []) )
);
};
document.body.render(
Card({}, {
header: [ h3("Title") ],
default: [ p("Body text") ],
footer: [ button("OK") ]
})
);Icon Component (SVG rendering)
Components aren't limited to HTML elements. An icon component that renders SVG paths from a dictionary is a scalable, flexible way to manage icons across your app:
let icons = {
check: 'M5 12l5 5L20 7',
close: 'M6 6l12 12M18 6L6 18',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z',
upload:'M12 15V3m0 0L7 8m5-5l5 5M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2'
};
define('an-icon', {
$({ icon, color }) {
if (color) this.style.stroke = color;
return mx.svg({ viewBox: '0 0 24 24' },
mx.path({ d: icons[icon] || '' })
);
}
});
// Use it
document.body.render(
mx.div({ style: 'display:flex; gap:16px' },
mx.anIcon( { icon: 'check' }),
mx.anIcon( { icon: 'star', color: 'gold' }),
mx.anIcon( { icon: 'upload', color: '#4285f4' })
)
);SVG in mx
mx handles SVG elements the same way as HTML. mx.svg(), mx.path(), mx.circle() all work. Attributes like viewBox and stroke-width are set via setAttribute as expected.
Do not nest a component directly under mx.svg
Inside an SVG host, mx creates children with createElementNS - and that branch does not run the define() mixin, so a custom component placed as a direct child of mx.svg() never gets its $ and renders empty, with no error:
define('chart-bar', { $({ x }) { return mx.rect({ x, width: 10, height: 50 }); } });
// ✗ component as a direct SVG child -> $ never runs, renders nothing
root.render(mx.svg({ viewBox: '0 0 100 100' }, mx.chartBar({ x: 0 })));
// ✓ invert it: the component's $ RETURNS the svg
define('bar-chart', { $({ data }) {
return mx.svg({ viewBox: '0 0 100 100' },
data.map((v, i) => mx.rect({ x: i * 12, width: 10, height: v })));
} });
root.render(mx.barChart({ data: [40, 50, 30] }));Rule of thumb: a component owns an SVG subtree by returning mx.svg(...) from its $; it is never itself a raw SVG child.
Stateful components ($state)
Components hold persistent state in this.$state, which accumulates across $() calls via Object.assign. Destructure props with defaults at the top of $; call this.$({...}) to update. You rarely touch $state directly.
define('a-counter', {
$({ count = 0, step = 1 }) {
let { div, button, span } = mx;
return div(
button({ onclick: () => this.$({ count: count - step }) }, '−'),
span(count),
button({ onclick: () => this.$({ count: count + step }) }, '+')
);
}
});
document.body.render(
mx.aCounter({ count: 10, step: 5 })
);Updating components externally
Use dom() to get a real element reference, then call el.$({ ... }) to push new props into the component at any time:
define('step-indicator', {
$({ steps = [], currentStep = 1 }) {
let { div } = mx;
return steps.map((step, i) => {
let n = i + 1;
let state = n < currentStep ? 'done'
: n === currentStep ? 'active'
: '';
return div({ class: 'step ' + state },
div({ class: 'circle' }, n < currentStep ? '✓' : n),
div({ class: 'label' }, step)
);
});
}
});
// Create with dom() - get a real element
let stepper = dom('step-indicator', {
steps: ['Account', 'Profile', 'Confirm'],
currentStep: 1
});
// Later, advance the step
stepper.$({ currentStep: 2 });
// Even later
stepper.$({ currentStep: 3 });dom() vs mx()
mx() - returns a description (array with ._=true). Use inside render() calls where mx manages the DOM.dom() - returns a real HTMLElement. Use when you need a persistent reference to call .$() on later.
Component Naming Rules
The tag name you use at the call site and the name you pass to define() must resolve to the same string. There are two conversions in play, and they differ:
| Site | Conversion | Example |
|---|
define(name, ...) | lowercase only | 'my-button' → 'my-button' |
mx.camelCase() | camelCase → kebab-case | mx.myButton() → 'my-button' |
mx('string') | string passthrough | mx('my-button') → 'my-button' |
The safe rule: always use kebab-case in define(). Then the call site can use either camelCase or the string form:
// ✓ Correct - kebab-case in define(), either form at call site
define('my-button', { $() {...} });
mx.myButton() // → <my-button>
mx('my-button') // → <my-button>
// ✗ Wrong - camelCase in define() silently breaks
define('myButton', { $() {...} }); // stored as 'mybutton'
mx.myButton() // looks up 'my-button' (mismatch - no $ runs)
// ✗ Wrong - PascalCase produces a leading hyphen
mx.MyButton() // tag "-my-button" (invalid custom element name)Silent failure
The camelCase-in-define() mismatch produces no error - you get a bare custom element with no behavior. The component looks registered, but its $ function never runs. Use kebab-case and this can't happen.
What $ Can Return
$ must return one of two shapes. Anything else either crashes or produces wrong output:
| Return value | Behavior |
|---|
mx.tag(...) (a single description) | render(value) - element rendered as the sole child |
[child, child, ...] (an array) | render(...value) - spread as multiple children |
[] (empty array) | No children - element is emptied |
undefined / null / false | Renders nothing - the wrapper guards with value?._ and ... || [] |
| number, boolean, string | Crashes or mis-renders - spread on non-iterable |
The wrapper dispatches via value?._ ? render(value) : render(...value || []). Only mx descriptions (arrays tagged with ._) and plain iterables work; null/undefined/false fall through the || [] and render nothing (no crash).
// ✓ Single description
define('greeting', {
$({ name }) {
return mx.div('Hello, ' + name);
}
});
// ✓ Array of children
define('counter', {
$({ count }) {
return [
mx.button('-'),
mx.span(count),
mx.button('+')
];
}
});
// ✓ Empty array to render nothing
define('conditional', {
$({ visible }) {
if (!visible) return [];
return mx.div('visible content');
}
});
// ✗ Forgot the return keyword - crashes
define('broken', {
$({ msg }) {
mx.div(msg); // not returned!
}
});Methods Beyond $
define(name, obj) does Object.assign(element, obj) on every matching element created by mx or dom. That means every property on the definition object becomes a property on the element, not just $:
define('video-player', {
$({ src, playing }) {
this._video ||= dom.video({ src });
if (playing) this._video.play();
else this._video.pause();
return [this._video];
},
// Imperative API methods - available on every instance
playPause() {
let v = this._video;
v.paused ? v.play() : v.pause();
},
seek(seconds) {
if (this._video) this._video.currentTime = seconds;
},
reset() {
this.$state = {};
this._video = null;
}
});
// Later, from anywhere:
let player = document.querySelector('video-player');
player.playPause();
player.seek(30);Useful for exposing an imperative API alongside the declarative $ flow. Think of it as "what you'd put on a class prototype" in a traditional component framework - except it's just Object.assign, so the properties are instance-own, not inherited.
Gotcha
define(name, {}) with no $ still registers the object as a methods-mixin. Any element created with that tag gets the methods, but nothing renders automatically. Useful occasionally; confusing if accidental.
Tag-Level Macros (define on built-in tags)
define() doesn't check whether the tag name is custom or built-in - both work the same way. Defining on a built-in tag means every instance of that tag created via mx or dom picks up your methods, including $ or a custom $attrs. This is a feature.
It works because createElement is the single entry point: every element passes through Object.assign(element, components[tag]) on creation. Whatever you put in the registry attaches to every fresh node of that tag - no per-call-site change required.
Pattern 1: Hardening the platform
Anchors with target="_blank" need rel="noopener noreferrer" to avoid a reverse tabnabbing. The platform doesn't enforce it - you're expected to remember. Define on a once and forgetting is no longer possible:
define('a', {
$attrs(attrs) {
if (attrs.target === '_blank' && !attrs.rel)
attrs = { ...attrs, rel: 'noopener noreferrer' };
return Element.prototype.$attrs.call(this, attrs);
}
});
// Now every link is safe by construction
mx.a({ href: 'https://example.com', target: '_blank' }, 'Link')
// → <a href="..." target="_blank" rel="noopener noreferrer">Link</a>Pattern 2: Build-time expansion
If your build pipeline emits responsive image variants (foo.png, foo@2x.png, foo@3x.png), define on img to auto-build the srcset. Authors write one URL; the DOM gets all variants:
define('img', {
$attrs(attrs) {
if (attrs.src && /\.png$/.test(attrs.src) && !attrs.srcset) {
let base = attrs.src.replace(/\.png$/, '');
attrs = { ...attrs,
srcset: `${base}.png 1x, ${base}@2x.png 2x, ${base}@3x.png 3x`
};
}
return Element.prototype.$attrs.call(this, attrs);
}
});
mx.img({ src: '/hero.png', alt: 'Hero' })
// → <img src="/hero.png" srcset="/hero.png 1x, /hero@2x.png 2x, /hero@3x.png 3x" alt="Hero">Pattern 3: Project-wide defaults
Want every <video> to start muted and inline? Every <form> to preventDefault on submit? Every <dialog> to trap focus? Define it once:
define('video', {
$attrs(attrs) {
attrs = { muted: true, playsinline: true, preload: 'metadata', ...attrs };
return Element.prototype.$attrs.call(this, attrs);
}
});
define('form', {
$({ action, onsubmit }) {
this.onsubmit = e => { e.preventDefault(); onsubmit?.(e); };
return [];
}
});How it relates to compilers
This is the same idea as a Lisp macro or a JSX pragma: the source you write differs from the DOM that's emitted, but the transformation is local and predictable. Definition is global; effect is per-element; cost is whatever your method does at element-creation time. The author of the call site doesn't need to know - they just write mx.a(...) and the right thing happens.
Use sparingly, scope deliberately
Definitions on built-in tags affect every call site that uses mx.a, mx.img, etc. - including code you don't own (third-party widgets, future contributors, your future self after a year). If you define('div', ...) to add a logging side effect, you've made every <div> in the app expensive.
Reserve this pattern for behavior that should be true of every instance with no exceptions: security defaults, build-time conventions, project-wide accessibility rules. If only some instances need it, use a custom tag (mx.safeAnchor(...)) instead.
$state vs this._property
This is the single most common question from teams adopting mx. The short answer is: they are two different tiers with two different contracts, and you rarely want to mix them.
| this.$state.x | this._x |
|---|
| What it holds | Accumulated props from the parent | Internal bookkeeping - tracking values, DOM refs, caches, flags, timers |
| Who writes it | The parent (via el.$({...})) and the component itself (via this.$({...})) | Only the component itself |
| Merge behavior | Object.assign(this.$state, props) runs before every $() call | Plain own property - set directly, no merge |
| Read from outside | OK, for aggregation (e.g. form submit reading nameField.$state.value) | Forbidden - it's private |
| Write from outside | Forbidden - always go through comp.$({ key: newValue }) so the change flows through the declarative model | Forbidden - it's private |
Rule: pick the narrowest tier that holds the data's actual lifetime. If a parent needs to see or set it, it's $state. Everything else is this._.
define('player', {
$({ track, playing, progress = 0, onEnded }) {
// $state: props surface. The parent controls track + playing + progress.
// Sibling components can read them: player.$state.track, .progress, etc.
// _interval: internal bookkeeping. The parent has no business seeing it.
clearInterval(this._interval);
if (playing) {
this._interval = setInterval(() => {
let next = progress + 1;
if (next >= track.duration) onEnded?.();
this.$({ progress: next });
}, 1000);
}
// _waveCache: memoized per-track computation. Purely private.
if (track && this._waveTrack !== track.id) {
this._waveTrack = track.id;
this._waveBars = computeBars(track);
}
return [mx.div(track.title), mx.progress({ value: progress, max: track.duration })];
}
});Don't put internal bookkeeping in $state
Putting _interval, Maps, DOM refs, or any private data into $state pollutes the props namespace and risks collisions with props the parent might pass. It also makes the component look like it accepts those keys as props, which leaks implementation details to callers. Keep $state as the external API surface and this._ as internal memory.
Reading $state across components
Form submit handlers often aggregate $state from several sub-components:
// ✓ Reading is fine - standard pattern for form submit
function submit() {
return api.post('/users', {
name: nameField.$state.value,
role: roleDropdown.$state.selected,
avatar: fileInput.$state.files?.[0]
});
}
// ✗ Writing from outside is forbidden - bypasses the declarative flow
nameField.$state.value = 'Alice'; // don't do this
// ✓ Always go through $()
nameField.$({ value: 'Alice' });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 means the component is relying on this._ mutations as side effects and using $({}) to flush a render. That hides what actually changed and defeats the declarative model.
Fix: pass the actual changed values. this.$({ progress: newValue }). If a child callback triggered the re-render, have the callback pass data back to the parent as props (onChange, onSelect) and let the parent re-render with meaningful state.