Controlled vs uncontrolled (there is no "controlled")
React draws a hard line between controlled inputs (React owns the value, every keystroke flows through state) and uncontrolled ones (the DOM owns the value, you read it with a ref). mx has no such concept - because the DOM already does. An <input> holds its own value whether or not you touch it. You decide how involved you want to be:
// "Controlled" style: the DOM mirrors a variable on every render.
// Set .value each time and re-render on input. You always hold the
// value, so you can transform it (uppercase, mask, clamp) before it shows.
let value = "";
function render() {
host.render(
input({ ".value": value,
oninput(e) { value = e.target.value.toUpperCase(); render(); } })
);
}
// "Uncontrolled" style: the DOM owns the value. Don't set .value,
// don't re-render on input. Read it only when you need it.
host.render(
form({ onsubmit(e) {
e.preventDefault();
let data = new FormData(e.currentTarget); // read on submit
save(Object.fromEntries(data));
}},
input({ name: "title" }), // no .value, no oninput - it just works
button({ type: "submit" }, "Save")
)
); | "Controlled" style | "Uncontrolled" style |
|---|
| You write | .value + oninput + re-render | Nothing - just name |
| Source of truth | Your variable | The DOM element |
| Read the value | It's already in your variable | FormData / el.value on submit |
| Use when | You transform/validate as the user types | You only need the value at submit |
Default to uncontrolled
If you don't need the value mid-typing, don't bind it. Skipping .value and oninput means no per-keystroke re-render and less code - the form just works and you read it once on submit (the validation-on-submit example does exactly this). Reach for the controlled style only when you genuinely need to see or reshape every keystroke.
Reshaping while typing moves the caret
When you transform the value on every keystroke (uppercase, mask, clamp) and write it back via .value, the cursor jumps to the end of the field - because setting .value resets the caret. For a trailing-edit field nobody notices; for editing mid-string it's maddening. Either transform on blur instead of oninput, or capture and restore the caret around the re-render:
oninput(e) {
let pos = e.target.selectionStart; // remember caret
value = e.target.value.toUpperCase();
render();
e.target.setSelectionRange(pos, pos); // restore it after .value reset
}Field-level validation
Validating on submit (above) is form-level: one check, all at once. Field-level validation gives feedback per field - typically on blur (when the field loses focus), so you're not flagging an email as invalid while the user is still on the first character. Keep an errors map keyed by field name and re-render the offending field:
let { form, label, input, small, button, div } = mx;
let values = { email: "", age: "" };
let errors = {};
let host = dom.div();
let validators = {
email: v => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? "" : "Enter a valid email",
age: v => +v >= 18 ? "" : "Must be 18 or older"
};
function validateField(name) {
errors[name] = validators[name](values[name]);
render();
}
function field(name, labelText, type) {
return div(
label(labelText),
input({ name, type, ".value": values[name],
// re-validate while typing only AFTER the first error is shown -
// don't nag the user mid-entry
oninput(e) { values[name] = e.target.value; if (errors[name]) validateField(name); },
onblur() { validateField(name); }, // validate when the field loses focus
"aria-invalid": errors[name] ? "true" : "false"
}),
errors[name] ? small({ class: "error" }, errors[name]) : null
);
}
function render() {
host.render(
form({ onsubmit(e) {
e.preventDefault();
Object.keys(validators).forEach(validateField); // validate ALL on submit
if (Object.values(errors).every(msg => !msg)) alert("Submitted!");
}},
field("email", "Email", "email"),
field("age", "Age", "number"),
button({ type: "submit" }, "Create account")
)
);
}
render();
document.body.render(host);Validate on blur, re-validate on input
The pattern that feels best: validate a field when it loses focus, then - once it's showing an error - re-validate on every keystroke so the error clears the instant it's fixed. The if (errors[name]) guard in oninput is what gives you that "don't nag early, but forgive immediately" behavior. On submit, validate everything regardless.
Debounced input
Some input handlers are expensive - a search query, a filter over thousands of rows, a network request. Running them on every keystroke is wasteful. Debouncing waits until typing pauses, then runs once. It's a four-line helper, no library:
let { input, ul, li, div, p } = mx;
// Run fn only after input pauses for ms - one search per pause,
// not one per keystroke
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
let FRUITS = ["apple","apricot","banana","blueberry","cherry","grape","mango","melon","orange","peach"];
let results = [];
let host = dom.div();
let search = debounce(query => {
results = query ? FRUITS.filter(f => f.includes(query.toLowerCase())) : [];
render();
}, 250);
function render() {
host.render(
input({ type: "search", placeholder: "Filter fruit...",
oninput(e) { search(e.target.value); } }),
results.length ? ul(...results.map(r => li(r))) : null
);
}
render();
document.body.render(host);Watch the counter
Type quickly and the "searches actually run" counter ticks once per pause, not once per keystroke. The same debounce helper wraps any handler - resize, scroll, autosave. For the inverse (run immediately, then ignore until quiet) the shape is the same with a leading-edge flag.
Async validation
The hardest form case: validating a field against a server - "is this username taken?", "does this coupon exist?". Two problems compound. You don't want a request per keystroke (debounce), and responses can arrive out of order - the answer for "ali" might land after the answer for "alice" and overwrite it with a stale result. The clean fix is to abort the previous request as each new one starts: AbortController.abort() rejects the in-flight fetch with an AbortError, and the browser discards that response even if it's already on the way - so a stale answer can never reach your handler:
let { label, input, small } = mx;
let status = "idle"; // "idle" | "checking" | "available" | "taken" | "error"
let controller; // AbortController for the in-flight request
let host = dom.div();
let check = debounce(name => {
controller?.abort(); // cancel the previous request - its reply is discarded
if (!name) { status = "idle"; return render(); }
controller = new AbortController();
status = "checking";
render();
fetch("/api/username-free?name=" + encodeURIComponent(name), { signal: controller.signal })
.then(response => response.json())
.then(data => { status = data.free ? "available" : "taken"; render(); })
.catch(error => { if (error.name !== "AbortError") { status = "error"; render(); } });
}, 350);
function render() {
host.render(
label("Username"),
input({ name: "username", type: "text",
"aria-invalid": status === "taken" ? "true" : "false",
oninput(e) { check(e.target.value.trim()); } }),
status === "checking" ? small("Checking...") :
status === "available" ? small({ class: "ok" }, "✓ Available") :
status === "taken" ? small({ class: "error" }, "✗ Already taken") : null
);
}
render();
document.body.render(host);Abort, don't just ignore
Aborting cancels the request outright: the previous fetch rejects with AbortError (which you ignore), the browser drops any response already coming back, and the connection is freed. That's cleaner and cheaper than letting every request run to completion and discarding the result afterward. For pre-fetch code, xhr.abort() is the exact equivalent.
Keep a monotonic-sequence guard (let mine = ++seq; ...; if (mine !== seq) return) only as a fallback for async sources you genuinely can't cancel - a library promise with no cancellation, or a value read from a shared subscription. When you have AbortController, it makes the guard unnecessary.
Checkboxes & Radio Buttons
Checkboxes and radios use .checked property for state. Group radios with the same name.
let { form, label, input, button, div, h3 } = mx;
let agreed = false;
let payment = "credit";
function agreementForm(){
return form({
oninput(e){
if (e.target.name === "agree") agreed = e.target.checked;
if (e.target.name === "payment") payment = e.target.value;
demo.render(agreementForm());
},
onsubmit(e){
e.preventDefault();
alert(`Agreed: ${agreed}, Payment: ${payment}`);
}
},
h3("Terms Agreement"),
label(input({ type:"checkbox", name:"agree", ".checked": agreed }), " I agree to terms"),
h3("Payment Method"),
label(input({ type:"radio", name:"payment", value:"credit", ".checked": payment === "credit" }), " Credit Card"),
label(input({ type:"radio", name:"payment", value:"paypal", ".checked": payment === "paypal" }), " PayPal"),
button({ type:"submit" }, "Continue")
);
}
let demo = dom.div();
demo.render(agreementForm());
document.body.render(demo);Select Lists
Select elements use .value for the selected value and .selectedIndex for index.
let { form, label, select, option, button, div, p } = mx;
let country = "us";
function countryForm(){
return form({
oninput(e){
if (e.target.name === "country") {
country = e.target.value;
selectDemo.render(countryForm());
}
},
onsubmit(e){
e.preventDefault();
alert(`Selected country: ${country}`);
}
},
label("Country"),
select({ name:"country", ".value": country },
option({ value:"us" }, "United States"),
option({ value:"ca" }, "Canada"),
option({ value:"uk" }, "United Kingdom"),
option({ value:"de" }, "Germany")
),
p(`Selected: ${country.toUpperCase()}`),
button({ type:"submit" }, "Submit")
);
}
let selectDemo = dom.div();
selectDemo.render(countryForm());
document.body.render(selectDemo);Forms - multi-step wizard
Multi-step forms keep all state in a single object. Each step renders different fields, and navigation buttons move between them. The entire wizard re-renders on every state change.
let { div, h3, label, input, button, span, p } = mx;
let step = 0;
let data = { name: "", email: "", plan: "free" };
let host = dom.div();
function steps() { return ["Account", "Plan", "Confirm"]; }
function Wizard() {
let names = steps();
return div(
// Step indicator
div({ class: "steps" },
...names.map((s, i) =>
span({
class: i === step ? "active"
: i < step ? "done" : ""
},
span({ class: "num" }, i < step ? "✓" : i + 1),
s
)
)
),
// Step content
step === 0 ? div(
label("Name"),
input({ name: "name", type: "text", ".value": data.name,
oninput(e) { data.name = e.target.value; }
}),
label("Email"),
input({ name: "email", type: "email", ".value": data.email,
oninput(e) { data.email = e.target.value; }
})
) : step === 1 ? div(
h3("Choose a plan"),
label(
input({ type: "radio", name: "plan", value: "free",
".checked": data.plan === "free",
oninput() { data.plan = "free"; }
}), " Free"
),
label(
input({ type: "radio", name: "plan", value: "pro",
".checked": data.plan === "pro",
oninput() { data.plan = "pro"; }
}), " Pro ($9/mo)"
)
) : div(
h3("Confirm"),
p("Name: " + data.name),
p("Email: " + data.email),
p("Plan: " + data.plan)
),
// Navigation
div({ class: "wizard-nav" },
step > 0 ? button({
onclick() { step--; host.render(Wizard()); }
}, "Back") : null,
step < 2 ? button({
onclick() { step++; host.render(Wizard()); }
}, "Continue") : button({
onclick() { alert("Done! " + JSON.stringify(data)); }
}, "Submit")
)
);
}
host.render(Wizard());
document.body.render(host);Wizard pattern
Keep all form data in one object and a step counter. Each step is a ternary branch that renders different fields. Navigation buttons increment/decrement the step and re-render. The same pattern scales to any number of steps.