Forms - oninput (simple)

For basic UX, a single oninput on the form (event delegation) is all you need.

let { form, label, input, button, div } = mx;

let name = "", email = "";
let host = dom.div();

function update() {
  host.render(
    form({
      oninput(e) {
        if (e.target.name === "name") name = e.target.value;
        if (e.target.name === "email") email = e.target.value;
        update();
      },
      onsubmit(e) {
        e.preventDefault();
        alert(`Submitting: ${JSON.stringify({ name, email })}`);
      }
    },
      label("Name"),
      input({ name: "name", type: "text", ".value": name }),
      label("Email"),
      input({ name: "email", type: "email", ".value": email }),
      button({ type: "submit" }, "Submit"),
      name ? div("Hello, " + name + (email ? " (" + email + ")" : "")) : null
    )
  );
}

update();
document.body.render(host);

Tip

If your UI changes based on input (e.g., selecting a mode reveals different fields), just re-render the section/container when state changes. mx updates minimally.

Forms - validation on submit

From a UX perspective, validating on submit is often simpler and less noisy:

let { form, label, input, small, button, div } = mx;

function loginForm(errors = {}){
  return form({
    onsubmit(e){
      e.preventDefault();
      let data = new FormData(e.currentTarget);
      let user = (data.get("user") || "").trim();
      let pass = (data.get("pass") || "").trim();
      let errs = {};
      if (!user) errs.user = "Username is required";
      if (!pass) errs.pass = "Password is required";
      if (Object.keys(errs).length) {
        container.render(loginForm(errs)); // re-render with errors
      } else {
        alert("Logged in!");
      }
    }
  },
    label("User"),
    input({ name:"user", type:"text", ".ariaInvalid": !!errors.user }),
    errors.user ? small({ class:"error" }, errors.user) : null,
    label("Pass"),
    input({ name:"pass", type:"password", ".ariaInvalid": !!errors.pass }),
    errors.pass ? small({ class:"error" }, errors.pass) : null,
    button({ type:"submit" }, "Login")
  );
}

let container = dom.div();
container.render( loginForm() );
document.body.render(container);

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-renderNothing - just name
Source of truthYour variableThe DOM element
Read the valueIt's already in your variableFormData / el.value on submit
Use whenYou transform/validate as the user typesYou 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.

Forms - conditional UI

Sometimes changing a select should switch the whole form. Keep a tiny state var and re-render:

let { form, label, select, option, input, button, div } = mx;

let mode = "email"; // "email" | "phone"

function contactForm(){
  return form({
    oninput(e){
      if (e.target.name === "mode") {
        mode = e.target.value;
        host.render(contactForm()); // re-render form
      }
    },
    onsubmit(e){ e.preventDefault(); alert("OK"); }
  },
    label("Preferred contact"),
    select({ name:"mode", ".value": mode },
      option({ value:"email" }, "Email"),
      option({ value:"phone" }, "Phone")
    ),
    mode === "email"
      // The reconciler reuses the same <input> node across the switch (same tag,
      // same position), so set .value explicitly or the typed text carries over.
      ? div(label("Email"), input({ type:"email", name:"email", ".value":"" }))
      : div(label("Phone"), input({ type:"tel", name:"phone", ".value":"" })),
    button({ type:"submit" }, "Save")
  );
}

let host = dom.div();
host.render(contactForm());
document.body.render(host);

Why the explicit .value: '' reset

The reconciler matches children by tag and position, so the email field's <input> and the phone field's <input> are the same node reused across the switch - only its type/name attributes change. Its DOM value (what the user typed) isn't in the description, so the reconciler never touches it, and the text would carry over into the other field. Setting .value: '' clears the reused node on switch. (If you wanted to keep a value per mode, you'd set .value to that mode's stored string instead.)

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.