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);

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"
      ? div(label("Email"), input({ type:"email", name:"email" }))
      : div(label("Phone"), input({ type:"tel", name:"phone" })),
    button({ type:"submit" }, "Save")
  );
}

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

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.