@zoijs/forms

A tiny, native-forms-first helper. It keeps a form's values, errors, and touched state in Zoijs reactive state — you still write ordinary <input>s and submit with @zoijs/action.

npm install @zoijs/core @zoijs/forms

Or with no install, from a CDN with an import map — exact versions, with an integrity hash for every module (regenerate the whole map to upgrade; see Installation → From a CDN):

<script type=class="tok-string">"importmap">
{
  "imports": {
    "@zoijs/core": "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/index.js",
    "@zoijs/forms": "https://cdn.jsdelivr.net/npm/@zoijs/forms@0.1.2/src/index.js",
    "@zoijs/action": "https://cdn.jsdelivr.net/npm/@zoijs/action@0.2.0/src/index.js"
  },
  "integrity": {
    "https://cdn.jsdelivr.net/npm/@zoijs/action@0.2.0/src/index.js": "sha384-xX/wRdVh4j+EErda1XJCYyK3q/eQnl+TE9mcl8yBknF5M5XKpgAoZkBBMrfQUt1Z",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/boundary.js": "sha384-sgP+7Uk+gh1Gk39OqMxZEo9Mox6uM6upgCbk9n4HKa8L3LywliSiXjQPQrjj/OG/",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/brand.js": "sha384-SQKAavcGif93XT58P5unWneM/VMR1rPsGM/wF63kNN4YaR2RdK2mu2IwULo9VYix",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/each.js": "sha384-ple5ogdJi5exkzQbgaQ8jnLLOymkmnG8dG/B8L36QkDbmIOiuRL+IdNVNNLYh+3N",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/html.js": "sha384-5sS5ABTdRpfXmhwZuIyhqcSO7fCY1VFVlYcolWRfLsRVzytc9JnUkjfX2Adfy8WI",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/mount.js": "sha384-eAm8c9K1SY4j66NnZ4pc4exzlek7iG67di9hU5U9S3PnaZnElkqRGpzOy2z4Uz2h",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/core/renderer.js": "sha384-H2K6FI3+kv2iPxyLb8V9e9Ux4X95PcPQGYu/h7Gq9rk4brE1nLHKFvI4iG5vt7Uz",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/index.js": "sha384-dDKK+RVA9Pj7X7aJvv0zgepWNZQi9W20zZ1JgACZ4EbIQ9LjvLsss8gsQGiyvcRb",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/prod.js": "sha384-2Wg5h5XW9gULwwZPet1azoni63tKm9iTs9FrAoLV0vGUa77h5WPgqsG/F3TFPuBy",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/computed.js": "sha384-nlAK86Jy1IR3yFSePE38l6cjZqcu5SdnYRVQxJh/+2YQL3WhTHdZ18yCKeKL4ffm",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/core.js": "sha384-dBKT0ohkuylmZHrv0HAjRtTxrMNyo6FZTk8Fqf6LH+aAi3moXikSLGdlPaeZi5IV",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/devtools.js": "sha384-KyEdZp4AcE9RdgocIKg0Q3NKTIyCUPnMi8LLPGrDDhRCJ55DA/JcqSZRFjoHHR5X",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/effect.js": "sha384-EMCI2Kpw6k0EUn5QPC6tJLoON+nUgQ7L25AXgIrWLmhMCLKJq/jP+C6Bk/P6gGDY",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/env.js": "sha384-41791FgWw6cq/pCRB6up+hZExngmUr1tAVxQG58SdvonImEcU6jK7g14jzZAPBFL",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/owner.js": "sha384-r8/NbofI3PXPfKvjxBuhWcRZGMJQn+Ldbne0ghFkjELu6SjvpaWIHnxF8FuScUAp",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/runtime.js": "sha384-5CQnBBrMFUfcq+ahlgE4DX/nG0ua+Bo0l70VtRkXVPWQyvGtXoSScup7FHsQqbA2",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/reactivity/state.js": "sha384-w2HVkiWmSdb8h7qqr4OViqHJrDqlD/oInr+5Lal+lSvXWu3SPuZv35WWDPsEOgUq",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/dom.js": "sha384-XsdCmmxzHfpBmOdz1648tCRrAMMHbWKIzvm4Aj2VIEpWJDIIaKmihqEARvxN6d+2",
    "https://cdn.jsdelivr.net/npm/@zoijs/core@1.9.0/src/utils/security.js": "sha384-7UafFG7FtgE/ZSVl+gEOKHc7qewaF0V6hKLEqgI9yj9mGTxz62GTzkrUPfbOXOtT",
    "https://cdn.jsdelivr.net/npm/@zoijs/forms@0.1.2/src/index.js": "sha384-X/FGb5OSWCJMNQNinl4MKeg2E+ygLuHu++CfVH5ITpWcgfzHa0rZQ480lukPzLja"
  }
}
</script>

What it does#

Add it when a form needs a little structure — tracking values, validation errors, and which fields have been touched. Forms never touches the network; it just holds state.

const login = form({ email: "", password: "" });

login.all();                     // { email: "", password: "" } — all values (reactive)
login.value("email");            // one field (reactive)
login.set("email", "a@b.com");   // update one field
login.error("email");            // one field's error (reactive)
login.isTouched("email");        // touched yet? (reactive)
login.touch("email");            // mark touched (e.g. on blur)
login.reset();                   // restore initial values, clear errors + touched

API#

MemberWhat it does
form(initialValues, options?)Create a form helper
all() / value(name)All values / one field's value (reactive)
set(name, value)Update one field
allErrors() / error(name)All errors / one field's error (reactive)
setError(name, msg) / clearError(name)Set / clear one field's error
allTouched() / isTouched(name) / touch(name)All touched / one field touched? / mark touched
reset()Restore initial values; clear errors + touched
validate(rules?)Run rules, set errors, return whether valid
handleSubmit(fn)Wrap a submit handler: prevents reload, calls fn(values)
values / errors / touchedAdvanced: raw reactive state ({ get, set, peek }) — prefer the readers above

Native form example#

value reads from the form, oninput writes back, onblur marks touched — plain HTML the whole way:

html`
  <input
    name="email"
    value=${() => login.value("email")}
    oninput=${(e) => login.set("email", e.target.value)}
    onblur=${() => login.touch("email")}
  />
  ${() => (login.error("email") ? html`<span class="err">${login.error("email")}</span>` : null)}
`;

Login & contact examples#

The repo ships two runnable examples: a login form (validation + submit + reset) and a contact form (a textarea, options.validate, and handleSubmit).

Validation#

Validation is just a map of field → function. A rule returns a message when invalid, or a falsy value when valid. No schemas, no dependencies.

const valid = login.validate({
  email: (value) => (value.includes("@") ? null : "Enter a valid email"),
  password: (value) => (value.length >= 8 ? null : "Minimum 8 characters"),
});
// valid === false, and login.error("email") is now set

You can also pass the rules once via options.validate and call validate() with no arguments.

Using it with @zoijs/action#

Forms holds state; action does the request. Validate, then submit:

import { action } from "@zoijs/action";

const submitLogin = action(async (values) => {
  await api.login(values);
});

html`
  <form onsubmit=${async (e) => {
    e.preventDefault();
    if (!login.validate(rules)) return; // forms validates
    await submitLogin.run(login.all()); // action does the request
  }}>
    ...
    <button disabled=${() => submitLogin.pending()}>Sign in</button>
  </form>
`;
handleSubmit is a thin convenience that prevents the default reload for you — the network call stays yours.

What this package intentionally does not do#

By design, to stay tiny: no form provider/context, no field registration, no field arrays, no schema validation, no resolver system, no async-validation engine, no controlled-component framework, and no third-party validation dependency.

Common mistakes#

  • Reading outside a binding. Wrap reads in an arrow: value=${() => login.value("email")}, not value=${login.value("email")}.
  • Expecting forms to submit for you. It doesn't — pair it with @zoijs/action.
  • Expecting auto-validation. set() only updates the value; call validate() on submit (or blur) when you want errors.
  • Validating-then-clearing on blur right before a click. Removing an error on blur can shift layout mid-click and swallow the submit — prefer validating on submit, or reserve space for the message.

Known limitations#

  • Flat values — no nested objects or field arrays.
  • Validation is manual and synchronous — no schemas, resolvers, or async rules.