@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/formsOr 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 + touchedAPI#
| Member | What 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 / touched | Advanced: 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 setYou 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")}, notvalue=${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; callvalidate()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.