Best Practices

Guidelines for writing clean, fast mx.js code.

Lowercase events

Always use lowercase event names: onclick, oninput, onchange. This is the DOM standard. onClick is a React convention that does not work with mx or vanilla JS.

// Correct
button({ onclick(){ doSomething() } }, "Click me")

// Wrong - won't fire
button({ onClick(){ doSomething() } }, "Click me")

Use properties for live values

For form elements, use the .property prefix to set live DOM properties: ".value", ".checked", ".selectedIndex". The attribute value only sets the initial value; the property controls what the user sees.

// Set the live value property
input({ ".value": currentText })

// Set the checked property
input({ type: "checkbox", ".checked": isChecked })

Security

mx sets text via textContent by default, which is safe from XSS. Avoid innerHTML with user-supplied content. If you need to render HTML, provide a dedicated escape hatch and sanitize first.

Re-render containers

When state changes, re-render the whole container rather than surgically mutating individual nodes. This is usually simpler and mx handles efficient DOM updates under the hood.

let state = { count: 0 };

function view() {
  return mx.div(
    mx.span(state.count),
    mx.button({ onclick(){ state.count++; host.render(view()) } }, "+")
  );
}

let host = dom.div();
host.render(view());

Destructure from mx

Pull the tags you need from mx at the top of your function. This keeps code tidy and saves a few characters per call.

let { div, span, button, input, ul, li } = mx;

// Now use them directly
div({ class: "app" },
  ul(
    li("Item 1"),
    li("Item 2")
  )
);

Single source of truth

Keep state in a single object - either a component's $state or a local variable - and re-render from it. Avoid scattering state across multiple variables or DOM nodes.

// Good: state in one place
let state = { name: "", email: "", agreed: false };

function view() {
  let { form, label, input, button } = mx;
  return form({ onsubmit(e) { e.preventDefault(); alert(JSON.stringify(state)) } },
    label("Name"), input({ ".value": state.name, oninput(e){ state.name = e.target.value } }),
    label("Email"), input({ ".value": state.email, oninput(e){ state.email = e.target.value } }),
    button({ type: "submit" }, "Save")
  );
}

Guard component definitions

When defining components in demos or lazy-loaded code, guard against double registration. The components global is a plain object - check before calling define():

if (!components['my-widget']) {
  define('my-widget', {
    $({ label }) {
      return mx.div({ class: 'widget' }, label);
    }
  });
}

// Safe to call multiple times
document.body.render(mx.myWidget({ label: 'Hello' }));

Use dom() for persistent references

When you need to call .$() on a component later (e.g., from an event handler or timer), create it with dom() instead of mx(). dom() returns the real element immediately:

// Create a real element with dom() - you can call .$() on it later
let picker = dom('date-picker', { selectedDate: new Date() });

// Update from an external event
button.onclick = () => {
  picker.$({ selectedDate: new Date() });
};

// Compare: mx() returns a description, not an element
// This does NOT give you a callable reference:
// let x = mx.datePicker({ selectedDate: new Date() });
// x.$(...) ← TypeError! x is an array, not an element

Summary

  • Lowercase events: onclick, not onClick.
  • Use properties for live values: ".value", ".checked", ".selectedIndex".
  • Security: prefer textContent (mx does) over innerHTML.
  • Re-render containers: it's usually simpler; mx updates efficiently.
  • Destructure from mx to keep code tidy.
  • One source of truth: keep state in a single object and re-render from it.
  • Guard definitions: check components[name] before define().
  • dom() for references: use dom() when you need to call .$() later.

Pitfalls & FAQs

"Why doesn't onClick fire?"

Because browser event properties are lowercase. Use onclick. This is not React - the DOM standard uses lowercase event handler properties like onclick, oninput, onchange, etc.

Common mistake

onClick, onChange, onInput are React conventions. In mx (and the DOM), use onclick, onchange, oninput.

"My input value doesn't change."

Set ".value" (property), not value (attribute). The HTML value attribute only sets the initial default value. The DOM property .value controls what's actually displayed. mx supports this with the . prefix:

// This sets the property (correct)
input({ ".value": currentText })

// This sets the attribute (initial value only)
input({ value: currentText })

Note: mx does auto-sync the value, checked, and selected attributes to their properties as a convenience. But using the . prefix is more explicit and recommended.

"Do I need keys for lists?"

Not by default. mx updates positionally - it walks children left to right and reuses nodes by tag name. This is fine for most lists. But if rows contain stateful elements (toggles, checkboxes) and you reorder, the state stays in place while data moves around it.

The fix is simple: create real DOM elements with dom() and store references. render() identity-matches real DOM nodes - it moves them physically instead of diffing, so all internal state travels with the element:

// Create a persistent DOM element per data item
let rowMap = new Map(
  data.map(d => [d.id, dom('a-row', { columns, data: d })])
);

// Render into table
table.render(...data.map(d => rowMap.get(d.id)));

// On sort: reorder references - render moves the actual nodes
let sorted = [...data].sort((a, b) => a.name.localeCompare(b.name));
table.render(...sorted.map(d => rowMap.get(d.id)));

// Update a single row's data without recreating it
rowMap.get(id).$({ columns, data: newData });

This works because render() checks existingChild != newChild by reference. Same node in the same slot is a no-op. Different node gets moved. No framework overhead - just DOM identity.

"Is this like React?"

No virtual DOM, no JSX required. It's plain DOM with a tiny helper. mx creates lightweight descriptions that render() reconciles against live DOM nodes. There's no component lifecycle, no hooks, no state management built in - just DOM.

If you want JSX, use any transform that outputs calls to mx - but it's not required.

"How do components talk to each other?"

Three ways, from simplest to most flexible:

Communication patterns

  • Callbacks - pass functions as props: mx('my-comp', { onchange: fn }). The component calls the function when something happens. Best for parent-child communication.
  • Shared state - components read from and write to a shared object, then re-render their container. Best for sibling components in the same view.
  • Event bus - a tiny pub/sub for decoupled communication across the app:
// Simple event bus (~8 lines)
let bus = (_ => {
  let subs = new Map;
  return {
    on(e, fn) {
      if (!subs.has(e)) subs.set(e, new Set);
      subs.get(e).add(fn);
    },
    send(e, data) { subs.get(e)?.forEach(fn => fn(data)); }
  };
})();

// Component A sends
bus.send('user-selected', { id: 42 });

// Component B listens
bus.on('user-selected', user => {
  detailPanel.$({ userId: user.id });
});

Key differences from React

  • No virtual DOM - mx patches real DOM directly.
  • No JSX required - use plain JS function calls.
  • No build step - drop a script tag and go.
  • this in event handlers refers to the actual DOM element.
  • Event names are lowercase (onclick, not onClick).
  • ~849 bytes brotli vs ~40KB+ for React + ReactDOM.