How $state works
Every component has a this.$state object that persists across renders. When this.$({ ... }) is called, the argument is shallow-merged into $state via Object.assign, then the $ function runs with the merged state.
// What happens inside the framework:
interfaceObject.$ = function(state) {
let elements = $.call(this, Object.assign(this.$state ||= {}, state));
this.render(elements);
};This means every property you pass to this.$() is merged into the existing state. Properties you don't pass are left untouched.
Shallow merge, not deep
Only top-level keys are merged. Nested objects and arrays are replaced, not recursively merged. If you pass this.$({ items: newArray }), the old array is gone.
Initializing state with ??=
Because $state persists, you need a way to set defaults only on the first render. The nullish assignment ??= pattern does exactly this:
define('a-counter', {
$({ count = 0, step = 1 }) {
let { div, button, span } = mx;
// First render: $state.count is undefined, so ??= sets it
// Later renders: $state.count already has a value, ??= skips
let c = (this.$state.count ??= count);
return div(
button({ onclick: () => this.$({ count: c - step }) }, '−'),
span(c),
button({ onclick: () => this.$({ count: c + step }) }, '+')
);
}
});
document.body.render(mx.aCounter({ count: 10, step: 5 }));Why ??= matters
Without ??=, clicking + would set $state.count to 15, but if the parent re-renders and passes count: 10 again, the merge would overwrite your local state. With ??=, the component keeps its own count because $state.count is already set.
Generating persistent IDs
Components like checkboxes and switches need a unique id to link <label> and <input>. Generate it once and store it in $state:
let counter = 0;
define('a-checkbox', {
$({ label, checked, onchange }) {
let { div, input, label: lbl } = mx;
// Generate ID once, reuse on every re-render
let id = (this.$state.id ??= 'chk-' + counter++);
return div(
input({ type: 'checkbox', id, '.checked': checked,
onchange(e) { onchange?.(e.target.checked); }
}),
lbl({ for: id }, label)
);
}
});The if (!id) guard works the same way - it only runs when $state.id is falsy (first render).
Clearing and resetting state
Because state auto-merges, clearing a value requires you to explicitly pass the reset value. Setting a key to undefined won't remove it from the object - you need to pass the empty value.
define('tag-list', {
$({ items }) {
let selected = (this.$state.selected ??= new Set);
return [
mx.div(
...items.map(item =>
mx.span({
class: selected.has(item) ? 'tag active' : 'tag',
onclick: () => {
selected[selected.has(item) ? 'delete' : 'add'](item);
this.$({ items }); // re-render, selected persists
}
}, item)
)
),
selected.size
? mx.button({ onclick: () => {
// Must pass a NEW Set to replace the old one
this.$({ items, selected: new Set });
}}, 'Clear all')
: null
];
}
});
document.body.render(
mx.tagList({ items: ['bug', 'feature', 'docs', 'urgent'] })
);Mutate or replace?
- Mutating (
selected.add(item)) works because the Set is the same object in $state. It persists automatically. - Replacing (
this.$({ selected: new Set })) is required for clearing or resetting. The merge replaces the old Set with a new empty one.
Reading state in callbacks
Event handlers defined in the render function close over the current render's variables. But sometimes you need to read the latest state from a callback that fires later. Access this.$state directly:
define('date-picker', {
$({ selectedDate, onSelect }) {
// Store callback in $state so it's always current
this.$state.onSelect = onSelect;
return buildCalendar(selectedDate, day => {
// Read the latest callback, not a stale closure
this.$state.onSelect?.(day);
});
}
});Closures vs $state
Variables from destructuring (let { onSelect } = props) are frozen at render time. If the parent passes a new callback on the next render, the old closure still references the old one. Storing it in $state ensures you always read the latest value.
Signal-lite Reactivity (sample pattern)
mx has no built-in state management - and doesn't need one. Re-rendering a container from a plain variable covers most cases. But if you want a pub/sub primitive, here's a sample implementation in ~20 lines. This is not part of mx; it's just a pattern you can copy and adapt.
The idea: a getter function with .set() and .subscribe() methods. The bind() helper wires a signal to a DOM container so it re-renders automatically on every change.
// ~20 lines
let signal = (v) => {
let subs = new Set();
let get = _ => v;
get.subscribe = (fn) => (subs.add(fn), _ => subs.delete(fn));
get.set = (nv) => { v = nv; subs.forEach(fn => fn(nv)); };
return get;
};
let bind = (place, view, sig) => {
place.render(view(sig()));
return sig.subscribe(v => place.render(view(v)));
};How it works
signal(initialValue) returns a getter function. Call it to read the value..set(newValue) updates the value and notifies all subscribers..subscribe(fn) registers a listener; returns an unsubscribe function.bind(el, viewFn, signal) renders viewFn(value) into el and auto-updates on changes.
Counter demo using signals
Create a signal, bind it to a host element, and update it from event handlers:
// Usage
let count = signal(0);
let host = dom.div();
bind(host, v => mx.div(
mx.button({ onclick(){ count.set(v - 1) } }, "−"),
mx.span(v),
mx.button({ onclick(){ count.set(v + 1) } }, "+")
), count);
document.body.render(host);Just a pattern
This is not part of mx - it's a sample implementation. For most apps, re-rendering a container from a plain variable is simpler and sufficient. Copy this if you find it useful; modify or replace it as you see fit.