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 (simplified):
interfaceObject.$ = function(state) {
let out = $.call(this, Object.assign(this.$state ||= {}, state));
// A single mx description is passed whole; an array of children is SPREAD.
out?._ ? this.render(out) : this.render(...out || []);
};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.
Reading and updating state
In practice, almost every component uses the same minimal pattern: destructure props with defaults, call this.$({...}) to update. $state accumulates behind the scenes - you rarely reference it directly.
define('a-counter', {
$({ count = 0, step = 1 }) {
let { div, button, span } = mx;
// count is destructured from $state after the merge.
// Default (= 0) applies on first render; later renders reflect
// whatever the parent or the component last set via $().
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 }));Walkthrough:
- First render: parent calls
el.$({ count: 10 }). The wrapper merges into $state, then calls your $ with the merged object. Destructure gives count = 10. - User clicks +:
this.$({ count: 11 }) merges into $state and re-renders. Destructure gives count = 11. - Parent re-renders without passing count: merge is a no-op for that key. Destructure sees whatever value the component last committed.
- Parent re-renders passing count: 10: merge overwrites. Local value is replaced by the parent's value - which is usually what a parent wants.
The parent always wins the merge
The wrapper does Object.assign(this.$state, props) before your $ runs. If a parent re-renders and re-passes { count: 10 }, locally updated $state.count is overwritten to 10. ??= can't help because $state.count is already non-nullish by then.
If you want state that survives parent re-passes, use a different key - put the prop as seed and keep local state separately:
define('counter', {
$({ initial = 0 }) {
// 'initial' is the seed; 'count' is the local state
this._count ??= initial;
return [
mx.button({ onclick: () => { this._count--; this.$({}); } }, '−'),
mx.span(this._count),
mx.button({ onclick: () => { this._count++; this.$({}); } }, '+')
];
}
});Note this pattern needs this.$({}) to trigger a re-render - which is normally a code smell. The better fix is to have the parent own the state and pass it down: truly local state that survives parent overrides is surprisingly rare in practice.
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 ??= assigns only when $state.id is nullish - so the id is generated once on the first render and reused unchanged on every later one.
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.
Cross-component coordination (the event bus)
$state handles state within a component, and props pass state down to children. But siblings that don't share a parent - a cart button and a header badge, a filter pill and a results table - need a sideways channel. The answer isn't a store library; it's a pub/sub bus, and it fits in about a dozen lines:
// The whole primitive: a Map of channel name -> Set of listeners.
// ~200 bytes of source. No dependency, no build step.
let bus = (() => {
let channels = new Map;
return {
on(name, fn) {
let set = channels.get(name) ?? channels.set(name, new Set).get(name);
set.add(fn);
return () => set.delete(fn); // returns its own unsubscribe
},
emit(name, data) {
channels.get(name)?.forEach(fn => fn(data));
}
};
})();That's the entire thing: a Map of channel name to a Set of listeners. emit fires them; on registers one and hands back a function that removes it. The unsubscribe-on-subscribe return is the important detail - it's what makes cleanup a single line wherever you listen.
Producers and consumers
Anything can emit; anything can listen. Components that mount and unmount subscribe on attach and unsubscribe on detach, so they never leak or fire against dead DOM:
// Producer: anything can emit - a click, a fetch callback, a socket message
mx.button({ onclick: () => bus.emit('cart:add', item) }, 'Add to cart')
// Consumer A - plain app-level wiring, no component required:
let badge = dom.span('0');
let count = 0;
bus.on('cart:add', () => badge.textContent = ++count);
// Consumer B - a component that comes and goes. Subscribe on attach,
// unsubscribe on detach, so a removed component stops listening.
class CartBadge extends HTMLElement {
connectedCallback() { this._off = bus.on('cart:add', d => this.bump(d)); }
disconnectedCallback() { this._off(); } // exactly the cleanup .on() handed back
bump() { /* update the view */ }
}
customElements.define('cart-badge', CartBadge);The component half of that pairing is the subscription lifecycle - subscribe in connectedCallback, call the returned unsubscribe in disconnectedCallback. For app-level wiring that lives as long as the page, you can just on() and never unsubscribe.
Live: one channel, two listeners
The button below knows nothing about what reacts to it. Two independent views both subscribed to the like channel; the producer just emits:
let bus = (() => {
let channels = new Map;
return {
on(name, fn) {
let set = channels.get(name) ?? channels.set(name, new Set).get(name);
set.add(fn);
return () => set.delete(fn);
},
emit(name, data) { channels.get(name)?.forEach(fn => fn(data)); }
};
})();
let likes = 0;
let count = dom.strong('0 likes');
let log = dom.div();
// Two independent views, both listening to one channel - neither knows
// about the other, or about the button that fires the event
bus.on('like', () => count.textContent = (++likes) + ' likes');
bus.on('like', () => log.prepend(dom.div('❤ someone liked this')));
let host = dom.div();
host.render(
dom.button({ onclick: () => bus.emit('like') }, 'Like'),
count,
log
);
document.body.render(host);Funnelling into one update
In a dashboard where many components share one source of truth, don't let each component react to the bus independently - funnel events into a single updateAll() that pushes derived state to everyone in one pass. That pattern, and the production event table behind it, are on the Architecture page.
Across tabs, only when you need it
The bus above is in-memory: synchronous, no serialization, no copies. That's a feature - in-tab eventing should stay that cheap. BroadcastChannel is for cross-tab messaging, and using it for in-tab coordination is heavy-handed. When you genuinely need other tabs to react (logout everywhere, sync a cart across windows), bolt it on as a thin forwarder rather than replacing the bus:
// In-tab eventing should stay in-tab: the bus above is synchronous and
// serialization-free. Add BroadcastChannel ONLY when other tabs must hear
// it too - as a thin forwarder on top, not the default path.
let tabs = new BroadcastChannel('app');
tabs.onmessage = e => bus.emit(e.data.name, e.data.data); // remote -> local bus
function emitEverywhere(name, data) {
bus.emit(name, data); // local listeners, synchronous, no copy
tabs.postMessage({ name, data }); // other tabs (data must be structured-cloneable)
}
// emitEverywhere('auth:logout') -> every open tab logs outHow small this is
The bus is roughly 200 bytes of source - a Map of Sets and two methods. The equivalent state primitive a compiled framework ships is several times larger, and it's a fixed cost in every bundle whether the app coordinates anything or not. Here it's code you can read in full, paste once, and own.
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.