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:

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 out

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