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.