@zakkster/lite-form
v1.4.0
Published
Headless reactive forms for @zakkster/lite-signal. One validator per keystroke, hoisted schema (Zod/Yup), ~1.5M keystrokes/sec on a 100-field form -- 8x faster than the hand-written pattern. No DOM, no VDOM, no compiler.
Maintainers
Readme
@zakkster/lite-form
Headless reactive forms for
@zakkster/lite-signal. No DOM, no virtual DOM, no compiler. Typing in one field of a 100-field form runs exactly one validator -- every other field is a cached read.
npm i @zakkster/lite-form @zakkster/lite-signal @zakkster/lite-projectimport { createForm } from "@zakkster/lite-form";
const form = createForm({
initialValues: { email: "", password: "" },
validators: {
email: (v) => /@/.test(v) ? null : "invalid email",
password: (v) => v.length >= 8 ? null : "min 8 chars",
},
onSubmit: async (vals) => fetch("/api/login", { method: "POST", body: JSON.stringify(vals) }),
});
// Bind to anything -- vanilla DOM, lite-element, your own renderer:
const e = form.field("email");
emailInput.value = e.value();
emailInput.addEventListener("input", (ev) => e.set(ev.target.value));
// e.error() is a live reactive read -- null until shownHeadline (measured on Node 26, see Benchmarks):
~6.1 million keystrokes/sec on a 100-field form -- typing in one field runs
exactly one validator. ~15× faster than a hand-written "run all validators on
every change" form. Lifecycle: ~205K create+dispose/sec, ~1 byte retained per
form. Pool returns to baseline (stats().activeNodes === 0).
Table of contents
- Why this exists
- What you get
- Quickstart
- Validation modes
- The two validation pipelines
- Cross-field validation
- Schema (Zod / Yup)
- Async validation
- Field arrays
- Surfacing server errors (the
setFieldErrorstory) - Submit lifecycle
- API reference
- Benchmarks
- The engine
- Edge cases pinned down
- What this is not
- Browser / runtime support
- Peer dependency
- FAQ
- License
Why this exists
A small set of design constraints picked deliberately:
- One validator per keystroke. When you type in
email, lite-form runsemail's validator and nothing else. The other 99 fields keep their cached error values. There is no<form>re-render, no validation pass over the whole shape, no diff. Cutoff-gated computeds + reveal-gated display do this for free. - One schema run per keystroke, not one per field. If you use a form-level
schema (Zod, Yup, custom), it's hoisted into a single computed that runs
exactly once per change. Every field reads
schema[path]as a cached lookup; lite-signal'sObject.iscutoff means only fields whose error actually flipped propagate to the DOM. - Validity vs. display are split.
isValid()always reflects true validity -- drives a submit button correctly from the first render. A field'serror()is reveal-gated (change/blur/submit), so a pristine form doesn't scream "required" at the user before they've touched anything. - No DOM. No renderer. lite-form ships ~1,565 lines of pure state. Bind it
with
@zakkster/lite-signal-dom,@zakkster/lite-element, hand-writtenaddEventListener, or whatever you want. Forms are state, not components. - Pool-clean teardown.
form.dispose()frees every signal and computed.stats().activeNodesreturns to baseline. We test it on a 100-field form; the bench audits the global pool at the end of every run.
If you want a <Form> component, a renderer, a CSS framework, a server adapter,
or a 12-step wizard runtime -- this is the wrong library. One factory function,
two peer deps (lite-signal + lite-project, the projection engine BOTH modes ride
-- the import is static), ~21 KB minified.
What you get
const form = createForm({
initialValues: { /* shape your form here */ },
validators: { /* path -> (value, ctx) => message | null */ },
validate: /* optional: (values) => { path: message } -- Zod adapter goes here */,
fieldOpts: { /* path -> { parse, format } */ },
validateOn: "change" | "blur" | "submit",
onSubmit: async (values) => { /* ... */ },
registry: /* optional: a createRegistry() handle for isolation */,
});
// Per-field reactive state:
form.field("email").value() // reactive read (call to subscribe)
form.field("email").error() // reveal-gated displayed error
form.field("email").dirty() // reactive boolean
form.field("email").touched() // reactive boolean
form.field("email").set(value) // write
form.field("email").blur() // mark touched
form.field("email").reset() // back to initial
form.field("email").props() // { value, onInput, onBlur } -- spread onto an <input>
// Form-level reactive state:
form.isValid() // always live (not reveal-gated)
form.isDirty() // any field differs from initial
form.isSubmitting() // async onSubmit in flight
form.submitError() // last operational error (or null)
form.submitAttempted()// reveals all errors once true
// Imperative actions:
form.values() // untracked snapshot
form.setValues({ email: "x", password: "y" })// batched multi-set
form.reset() // back to initialValues; clears submit state
form.submit(ev?) // Promise<boolean> -- runs validation + onSubmit
form.dispose() // free every signal/computedQuickstart
A complete sign-in form, framework-agnostic. Wire it to any reactive primitive
(effect from lite-signal, lite-element's bind, lite-signal-dom, manual):
import { createForm } from "@zakkster/lite-form";
import { effect } from "@zakkster/lite-signal";
const form = createForm({
initialValues: { email: "", password: "" },
validators: {
email: (v) => v ? (/@/.test(v) ? null : "invalid email") : "required",
password: (v) => v.length >= 8 ? null : "min 8 chars",
},
onSubmit: async ({ email, password }) => {
const r = await fetch("/api/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!r.ok) throw new Error("login failed");
},
});
// Wire to plain DOM. Each input mirrors its field; each <span> shows the error.
const $ = (id) => document.getElementById(id);
for (const name of ["email", "password"]) {
const f = form.field(name);
const inp = $(name), err = $(name + "-error");
inp.addEventListener("input", (ev) => f.set(ev.target.value));
inp.addEventListener("blur", () => f.blur());
effect(() => { inp.value = f.value(); });
effect(() => { err.textContent = f.error() || ""; });
}
// Submit button only enabled while valid; reflects in-flight state.
const btn = $("submit");
effect(() => {
btn.disabled = !form.isValid() || form.isSubmitting();
btn.textContent = form.isSubmitting() ? "Signing in..." : "Sign in";
});
$("form").addEventListener("submit", (ev) => form.submit(ev));Validation modes
validateOn controls when errors become visible. It does NOT affect
isValid() -- that's always live.
| mode | error shown when... |
|------------|-------------------------------------------------------|
| "change" | the field becomes dirty (default -- keystroke reveal) |
| "blur" | the field is blurred for the first time |
| "submit" | the form has had at least one submit attempt |
A submit attempt always reveals everything, regardless of the mode. reset()
clears the submit-attempted flag, so a fresh form is pristine again.
The two validation pipelines
Both feed into the same field.error() read; you can use either, both, or
neither. They merge cleanly: per-field validators run first, schema fills in
what they don't cover.
Per-field validators
validators: {
email: (v, ctx) => /@/.test(v) ? null : "invalid email",
password: (v, ctx) => v.length >= 8 ? null : "min 8 chars",
}A per-field validator is a function (value, ctx) => string | null. It depends
only on its own field's value (plus anything it reads via ctx.get(path), see
Cross-field validation). The keystroke path is lean:
typing in email runs email's validator and no other.
Form-level schema
import { z } from "zod";
const schema = z.object({ email: z.string().email(), age: z.number().min(18) });
const form = createForm({
initialValues: { email: "", age: 0 },
validate: (values) => {
const r = schema.safeParse(values);
if (r.success) return {};
return Object.fromEntries(
r.error.issues.map(i => [i.path.join("."), i.message])
);
},
});The validate function runs in one hoisted computed, exactly once per
keystroke. Every field reads its slot from the result as an O(1) lookup.
Object.is cutoff means a field that stays "required" across keystrokes
doesn't re-render the DOM.
This means: plug in your Zod parse, your Yup schema, your hand-rolled validator -- the cost per keystroke is the cost of ONE schema run, not N.
Cross-field validation
A validator's ctx.get(path) is a tracked read: depending on another field
makes this field re-validate whenever the dependency changes.
createForm({
initialValues: { password: "", confirm: "" },
validators: {
confirm: (v, { get }) => v === get("password") ? null : "must match",
},
});Typing in password re-validates confirm automatically. There's no
dependency declaration, no dependsOn: ["password"], no manual subscription
-- the act of reading get("password") records the dependency. The same
mechanism lite-signal uses for computed.
Schema (Zod / Yup)
Adapters are 4-line functions. Plug them into validate:
// Zod
const zodAdapter = (schema) => (values) => {
const r = schema.safeParse(values);
return r.success ? {} :
Object.fromEntries(r.error.issues.map(i => [i.path.join("."), i.message]));
};
// Yup
const yupAdapter = (schema) => async (values) => {
try { await schema.validate(values, { abortEarly: false }); return {}; }
catch (e) { return Object.fromEntries(e.inner.map(i => [i.path, i.message])); }
};The form-level
validateschema is sync (one hoisted run per keystroke). Per-field ASYNC business validation is first-class since v1.3.0 -- see Async validation.
Schema errors merge with per-field validators: per-field message wins if present, otherwise the schema message shows. Useful for "the schema validates shape; the per-field validator checks availability".
Async validation
validatorsAsync gives a field an async lane beside its sync validator -- your
promise, sequenced by lite-form. There is no timer, no debounce, no transport
machinery inside lite-form: the validator returns a promise; lite-form
guarantees ordering.
const form = createForm({
initialValues: { username: "" },
validators: { username: (v) => v ? null : "required" }, // sync lane, instant
validatorsAsync: {
username: async (v) => {
const r = await fetch("/api/taken?u=" + encodeURIComponent(v));
return (await r.json()).taken ? "already taken" : null;
},
},
});
form.field("username").isValidating(); // true while the LATEST check is unsettled
form.isValidating(); // true while ANY field's check is unsettledThe ordering contract:
- Last write wins. Every trigger bumps a per-field sequence; a settlement (resolve OR reject) carrying a stale sequence is dropped whole -- no signal write, no error flash, no trace.
- Pending is not-yet-valid. While any async verdict is pending,
isValid()isfalse(strict fail-closed).submit()therefore refuses while validation is in flight -- a pending verdict can never race a submit into a false positive. - A rejection is a verdict. The latest rejection surfaces as the field's error message (a rejection can never leave the field valid); stale rejections are swallowed with no unhandled-rejection.
dispose()mid-flight is safe. A settlement arriving afterdispose()is a complete no-op.- Off-cost when unused. A form with no
validatorsAsyncallocates no async machinery and keeps the 1.2.0 keystroke numbers byte-for-byte; on a mixed form, sync-only fields pay nothing either (theirisValidatingis a shared frozen constant).
Debouncing (the lite-debounce recipe). Firing a server check per keystroke
is the caller's decision -- lite-form owns no timers. Hand the async lane a
debounced reader via asyncSources; the check then re-fires when the debounced
read changes instead of on every keystroke:
import { debounce } from "@zakkster/lite-debounce";
const form = createForm({
initialValues: { username: "" },
validatorsAsync: { username: checkAvailability },
asyncSources: {
// called once at construction, inside the form's own root:
username: (fld) => debounce(() => fld.value(), 300),
},
});An async-validated keystroke allocates -- promise machinery is inherent (measured 629.703 B/op, recorded in the torture tier, settlements outside the window). That cost is exactly why the debounce recipe exists; the sync path stays gated at zero.
Field arrays
Rows with identity (v1.4.0). Declare an array path in arrays and its rows get
stable keys: values, dirty, touched, errors, and async state travel with the
ROW when it moves -- never with its index.
const form = createForm({
initialValues: { rows: [{ id: "a", qty: 1 }, { id: "b", qty: 2 }] },
arrays: {
rows: {
key: (item) => item.id, // stable identity, derived once per row
validators: {
// local(sub) reads a sibling field IN THIS ROW (tracked):
qty: (v, { local }) => v <= local("cap") ? null : "over cap",
},
},
},
});
const rows = form.array("rows");
rows.keys(); // ["a", "b"] -- tracked, frozen snapshot
const k = rows.add({ id: "c", qty: 3 }); // -> "c"
rows.move("c", 0); // order-only: no row state is touched
form.field("rows.c.qty").set(4); // keyed paths are real paths...
rows.row("c").field("qty"); // ...and this is the SAME Field object
rows.remove("b"); // row disposed; its slots reclaimedThe laws:
- Opt-in per path. An array you do NOT declare stays exactly what it was:
a plain leaf value (set the whole array;
Object.isdirty). - One addressing model per declared path. Index paths (
rows.0.qty) and whole-array writes throw aTypeErroron a declared array -- the row API is the only door. Mixed addressing is how overlapping patches happen. - Reorder is order-only.
move()re-runs no validators and writes no row field -- proven by the torture tier (instrumented counters), not just promised. - Structure is state.
isDirtycovers adds/removes/reorders.toPatch()emits per-field entries for existing rows plus ONE{ path, structure: { order, added, removed } }entry per structurally dirty array; an added row ridesstructure.addedwith its full value and never emits overlapping field entries.commit()promotes structure + values into the baseline;reset()restores baseline rows and order. - Removal is teardown.
remove()disposes the row's signals and async lanes (an in-flight validation's settlement becomes a no-op) and reclaims its projection slots. Distinct-key add/remove churn holdsactiveNodesflat under the torture gate. - Merge is deferred, loudly. 2-arg
reinitialize(next, policy)throws on a form with declared arrays (keyed row merge is a recorded future design); 1-arg re-seeds arrays fully, keys re-derived from the new items.
Cost: a keystroke into a row field is gated in the same zero-allocation class as a flat field (0.119 B/op measured). Structure ops are O(rows), recorded: an add+remove pair ~8.9 KB, a move ~294 B.
Surfacing server errors (the setFieldError story)
lite-form has no setFieldError(path, message) API. It doesn't need one.
Per-field validators run inside a computed, so any signal they read is
tracked. The canonical pattern is: hold the server error in a signal; flip
it from your fetch handler. The field re-validates automatically.
import { signal, effect } from "@zakkster/lite-signal";
const usernameServerErr = signal(null); // null | "already taken" | ...
const form = createForm({
initialValues: { username: "", email: "" },
validators: {
username: (v) => {
if (!v) return "required";
if (v.length < 3) return "too short";
return usernameServerErr() || null; // tracked read
},
},
async onSubmit(values) {
const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
if (res.status === 409) {
const body = await res.json();
if (body.field === "username") usernameServerErr.set(body.message);
throw new Error("signup failed"); // surfaces in submitError too
}
},
});
// Clear the server error when the user starts editing -- the user has acknowledged it:
effect(() => { form.field("username").value(); usernameServerErr.set(null); });Why this is strictly nicer than a setFieldError method:
- No imperative call -- the field error is a function of
value × server-state, always. It can't get out of sync with the value (the classic "clear server error when user types" footgun is one effect, not a forgotten listener). - No race between programmatic write and reactive validate. The signal flip and the validator re-run are in the same reactive transaction.
- Disposal is free --
serverErris a plain lite-signal handle. Pass aregistrytocreateFormand they're cleaned up together. - Composable -- multiple validators can read the same server-error signal
(e.g. a generic
formErrplus per-field overrides). Try doing that with imperativesetFieldErrorcalls without re-applying them on every change.
For form-wide errors (the whole submission failed, not one field),
submitError() is already wired -- your onSubmit just throws.
Submit lifecycle
const ok = await form.submit(ev?);ev.preventDefault()ifevis provided.submitAttemptedflips totrue-> reveals all errors.- If
isValid()is false -> returnsfalsesynchronously. No call toonSubmit. isSubmittingflips totrue.onSubmit(values())is invoked with an untracked snapshot.- On success: returns
true,isSubmittingflips back,submitErroris cleared. - On throw:
ReferenceError/SyntaxError-> re-thrown afterconsole.error. These are structural bugs in your code, not legitimate submission outcomes; hiding them insubmitErrorwould be a debugging nightmare.- Any other error (including
TypeErrorfromfetch()network failure) -> stored insubmitError(), returnsfalse.isSubmittingresets.
submit() and onSubmit are both called inside untrack -- they don't
accidentally subscribe the calling effect to internal form state, and your
onSubmit can read auth.token() without that becoming a dependency.
Double-submit defense (concurrent submit() IS a footgun)
submit() is not internally deduped. If submit() is called twice while
one call is still awaiting onSubmit, both calls go through. Both onSubmit
invocations run concurrently against the same values snapshot. Both return
true if the request succeeds. This is intentional simplicity -- lite-form
holds no in-flight lock and no internal queue -- but it does mean a hotkey
that fires submit() from two places, or a parent component re-rendering and
re-attaching a handler, can double-fire your network request.
Three documented defenses, in order of how often you'll want each:
1. Disable the submit button (covers ~90% of cases -- UI forms with a single submit element):
effect(() => { btn.disabled = form.isSubmitting() || !form.isValid(); });2. Throttle the handler with @zakkster/lite-throttle (covers Enter-key + button, hotkey + button, two places that
both fire submit()):
import { throttle } from "@zakkster/lite-throttle";
const guardedSubmit = throttle(() => form.submit(), {
leading: true, // fire the first call
trailing: false, // drop subsequent calls in the window
wait: 500,
});
formEl.addEventListener("submit", (e) => { e.preventDefault(); guardedSubmit(); });3. Guard inside your own handler with isSubmitting.peek() (one-liner,
no extra dependency):
function onFormSubmit(e) {
e.preventDefault();
if (form.isSubmitting.peek()) return;
form.submit();
}Don't disable both -- pick the one that matches your UI surface. Disabling
the button alone won't help if submit() is also bound to Enter on the
document; that's where throttle wins.
API reference
createForm(config?) -> Form
| option | type | default |
|-----------------|-----------------------------------------------------|------------------|
| initialValues | Record<string, any> | {} |
| validators | Record<string, (value, ctx) => string \| null> | {} |
| validate | (values) => Record<string, string \| null> | -- |
| validatorsAsync | Record<string, (value, ctx) => Promise<string \| null>> | {} |
| asyncSources | Record<string, (field, ctx) => (() => any)> | the field's value |
| fieldOpts | Record<string, { parse?, format? }> | {} |
| validateOn | "change" \| "blur" \| "submit" | "change" |
| onSubmit | (values) => void \| Promise<void> | -- |
| registry | createRegistry() handle | default registry |
| source | live keyed source (e.g. a lite-store proxy) | -- |
| arrays | Record<string, ArrayConfig> -- keyed field arrays, see Field arrays | {} |
Passing source selects engine mode: the value core projects the live
source instead of the detached baseline. Edits stage as overlays (the source is
never written by an edit); commit() writes through. In this mode dirty is
overlay presence -- an authoritative source write under an un-overlaid field is
not an edit and never flips dirty; a conflicting write under an overlaid field
stays masked. Without source, the form projects the detached baseline through
the same engine -- lite-project is imported statically and required in BOTH modes.
Declared arrays are default-mode only: source + arrays together throw at
construction (there is no keyed baseline to travel with a row in source mode).
form.field(path) -> Field
Returns the reactive state for a field. Paths are dotted ("user.address.zip",
"items.0.qty" -- index segments descend into UNDECLARED arrays). Fields
declared in initialValues/validators/fieldOpts are eagerly allocated;
calling field() on an undeclared path creates one lazily on first access.
On a DECLARED array path the segment after the array path must be a live row
key ("rows.<key>.qty" is the same Field as row(key).field("qty")); an index
segment or whole-array write throws (see Field arrays).
Field
| member | type | notes |
|---------------|----------------------------|--------------------------------------------------------|
| path | string | |
| value | WritableSignal<T> | value() reads+tracks, value.peek() untracked |
| error | ReadSignal<string\|null> | reveal-gated; merges per-field + schema |
| rawError | ReadSignal<string\|null> | always-live validity (ignores reveal); drives isValid |
| dirty | ReadSignal<boolean> | !Object.is(value(), initialRef); in-place mutation does not flip it, set(newRef) does |
| touched | ReadSignal<boolean> | blurred at least once |
| isValidating| ReadSignal<boolean> | latest async check unsettled; async-validated fields only (a shared frozen false otherwise) |
| set(v) | function | |
| blur() | function | marks touched |
| reset() | function | back to initial value, clears touched |
| props() | () => FieldProps | { value, onInput, onBlur } -- spread onto <input> |
Form
| member | type | notes |
|--------------------|-------------------------------------|------------------------------------------------|
| field(path) | (string) => Field | |
| array(path) | (string) => ArrayHandle | keyed-row handle for a declared array: keys() / length() / structureDirty() tracked, row(key).field(sub), add(item, atIndex?) -> key, remove(key), move(key, toIndex) (see Field arrays); an undeclared path throws |
| values() | () => object | untracked snapshot; declared arrays materialize in order |
| setValues(patch) | (object) => void | batched multi-set; not atomic under a throwing entry (an illegal path throws loud, but earlier writes land) |
| reset() | () => void | restore initial, clear touched + submit state |
| commit(path?) | (path?) => void | fold dirty values into the baseline (all, or one path); committed fields go pristine, reset() now targets the committed state; values deep-copied through the whitelist; an unregistered path throws a TypeError (loud, never a lazy field creation) |
| toPatch() | () => FormPatchEntry[] | exactly the dirty paths as {path, from, to} (from = baseline, to = current); a field set back to its initial ref is excluded; plus one {path, structure: {order, added, removed}} entry per structurally-dirty declared array (entries never overlap); untracked + read-only, safe in an effect |
| reinitialize(next, policy?)| (next, policy?) => void | 1-arg: re-seed like initialValues (deep-copied + whitelist-validated BEFORE any state change -- atomic TypeError on bad input); drops every edit, absent paths re-seed undefined, clears touched + submit state; declared arrays re-seed fully (keys re-derived). With a policy: MERGES instead -- dirty fields survive unless echoed (see The engine); default-mode only (source mode throws -- use reconcile), and a form with declared arrays throws (keyed row merge is a recorded future design) |
| reconcile(policy?)| (policy?) => void | source-mode merge: drop exactly the overlays the source now agrees with (default Object.is); legal in default mode too (a no-op under the default policy, by design); the policy runs under the same purity latch as the 2-arg merge -- a mutating policy throws (v1.4.0) |
| submit(ev?, opts?)| (ev?, {patch?}) => Promise<boolean> | true if onSubmit ran without throwing; opts.patch: true posts toPatch() to onSubmit instead of values() (an empty patch still submits [] -- the caller checks .length) |
| isValidating | ReadSignal<boolean> | true while ANY field's async check is unsettled |
| isValid | ReadSignal<boolean> | always live |
| isDirty | ReadSignal<boolean> | |
| isSubmitting | ReadSignal<boolean> | |
| submitError | ReadSignal<Error \| null> | last operational throw |
| submitAttempted | WritableSignal<boolean> | set true to force-reveal |
| dispose() | () => void | free every node |
Benchmarks
Measured on Node 26 with --expose-gc. Run yourself: npm run bench.
| Scenario | N | ops/sec | transient/op | retained/op | |-------------------------------------------------------------------|--------:|------------:|-------------:|------------:| | A) create+dispose, small (3 fields, 1 validator) | 20K | ~205K | ~1.8 KB | ~0 B | | B) create+dispose, large (100 fields + per-field validators) | 2K | ~3.8K | ~9.6 KB | ~21 B | | C) keystroke on 1 of 100 fields (per-field validators) | 50K | ~6.1M | ~27 B | ~1 B | | D) keystroke on 1 of 100 fields (form-level schema, hoisted) | 50K | ~83K | ~638 B | ~1 B | | E) cross-field validation (pw + confirm, ctx.get) | 50K | ~21M | ~25 B | 0 B | | F) pure-JS baseline (handwritten, runs all 100 validators) | 50K | ~400K | ~318 B | 0 B |
Headline:
- A keystroke on a 100-field form is ~15× faster than the handwritten
pattern that re-runs every validator on every change (F vs C). The reason:
lite-form only invokes
f0's validator. The other 99 fields' validators are cached and never called -- theirerror()short-circuits on the reveal gate before readingrawError(). - Schema-validated forms cost ~12 µs per keystroke at N=100 (D) -- your Zod parse runs once per change, not N times. (Before 1.2.0 this cost ~33 µs, dominated by a per-keystroke snapshot clone; the engine's scratch tree removed it.)
- Cross-field validation is cheap.
ctx.getrecords a dependency on read; subsequent changes re-validate only the dependent. ~21M ops/sec. - Pool clean. All scenarios end with
stats().activeNodes === 0-- no leaked signals or computeds across 100K+ lifecycle cycles.
Numbers vary ~15% run-to-run with GC timing. The bench file is
bench/bench.mjs; copy it, modify, re-run.
The engine
As of v1.2.0 the value core rides a @zakkster/lite-project projection over the
S1 detached baseline. The default mode is a fromAccessors projection over
per-field seed copies plus a baselineRev signal; the engine owns a per-key
overlay signal and a projected computed, with the slot warmed at field creation.
Validation, reveal gating, and submit stay lite-form's own code -- the swap is
confined to how a field's value is stored and read.
The unification trick. field.set(v) compares Object.is(v, seed); on
equality it clears the overlay instead of staging it. So "overlaid" coincides
exactly with "dirty", and form.isDirty rides the engine's tracked
dirtyCount() rather than a separate walk.
The scratch-tree contract. Schema mode no longer clones the value tree per
keystroke. The internal materialization handed to validate() reuses a per-form
scratch tree -- leaves written in place, object leaves shared by reference --
rebuilt only on reinitialize/commit. The object passed to schema
validate() is therefore form-owned and transient: retaining or mutating it
is undefined behaviour. Public values() is unaffected -- it still returns a
fresh deep copy every call.
Every S1 contract survives verbatim: the baseline stays unreachable
(source.get returns the field's seed copy, never a baseline reference; commit
deep-copies in), dirty = !Object.is(value(), initialRef), the construction
whitelist / cycle / hostile-segment TypeErrors, and the copying snapshot with
its path-naming TypeError.
commit + toPatch round-trip. The new methods give you an explicit edit/baseline boundary -- diff the pending edits, ship them, then fold them in:
const form = createForm({ initialValues: { name: "Ann", role: "dev" } });
form.field("name").set("Bob");
form.isDirty(); // true
form.toPatch(); // [{ path: "name", from: "Ann", to: "Bob" }]
await save(form.toPatch()); // ship exactly the dirty paths
form.commit(); // fold edits into the baseline
form.isDirty(); // false -- every field pristine again
form.field("name").reset(); // stays "Bob" -- reset() targets the committed stateServer data while the user edits: reinitialize(next, policy). The 1-arg
form is unchanged (atomic re-seed, drops every edit). Passing a policy merges
instead: dirty fields survive unless the server echoed them. Per registered
field, with n = the deep-copied next leaf and d = the user's draft:
| field state | verdict | result |
|---|---|---|
| pristine | ADOPT | takes n; stays pristine; touched cleared |
| dirty, Object.is(n, d) or policy(n, d) === true | ECHO | overlay cleared; pristine at n; touched cleared |
| dirty, anything else | CONFLICT | draft kept (masks n); baseline re-seeds underneath -- reset() now lands n and toPatch().from === n; touched kept |
| path absent from next | the same table with n = undefined | |
The default policy is Object.is (confirm-on-echo). Deep-copied payloads mean
an OBJECT leaf can never Object.is-echo -- object edits are always conflicts
under the default; pass a structural policy when your leaves are objects. Only
=== true confirms (fail closed), a throwing policy is atomic (nothing
mutated), and a merge never touches submitAttempted/submitError -- a
background refresh must not un-reveal errors mid-flow. The policy must be
PURE: any mutating form call from inside the merge window (set, commit,
reset, a nested reinitialize, ...) throws a TypeError -- verdicts are
pre-scanned against a snapshot, and applying them over mutated state would be
silent corruption.
In source mode there is no detached baseline to re-seed, so the merge story is
form.reconcile(policy?) -- drop exactly the overlays the live source now
agrees with. reinitialize(next, policy) in source mode throws (loud, never a
silent fallback).
Allocation, measured on Node 26 (node --expose-gc --preserve-symlinks
test/torture.mjs):
| keystroke path | B/op | gate | |------------------------|-----------:|--------------------------------------| | flat, per-field | ~0 (noise) | gated | | dotted, 3-segment | 0.112 | GATED (<= 16384 B / 50K ops) | | schema mode | 113.440 | recorded baseline (was 20,990 in 1.1.0; ceiling 32768 B/op) | | async-validated | 629.703 | recorded (trigger + promise creation; settlements outside the window; see Async validation) | | row field (declared array) | 0.119 | GATED (<= 16384 B / 50K ops) |
Structure ops on declared arrays are O(rows), not keystroke-class -- recorded, ratcheted: an add+remove pair 8,855 B/op, a move 294 B/op (order snapshot + one reactive propagation; removal includes teardown + slot reclaim).
The schema-mode figure is a 185x fall from 1.1.0's 20,990 B/op -- the scratch tree replaced a full per-keystroke clone.
@zakkster/lite-project1.4.0 was falsified by lite-form's t6: a ~40 B/op hot-path context allocation inside the engine'sget/peek/set, invisible to lite-project's own pool-census gate. It was fixed upstream as 1.4.1 with the transient witness ported. Seedecisions/0002-engine.mdfor the full record. Hence the peer floor@zakkster/lite-project ^1.4.1.
Testing
lite-form ships 144 deterministic tests (node:test, zero runtime deps):
npm test # the fast suite
npm run torture # plus the torture gate: npm run torture
npm run verify # test + torture, the full gateThe torture gate (test/torture.mjs) proves the zero-GC and zero-leak claims
with @zakkster/lite-gc-profiler (a keystroke allocates nothing, provokes no
major GC) and @zakkster/lite-leak (every disposed form's field records are
collectable). It runs under --expose-gc --preserve-symlinks.
Development wiring. The witness peers and the signal core are linked as local symlinks so the gate measures the real, single lite-signal instance:
ln -s ../../../LiteSignal node_modules/@zakkster/lite-signal
ln -s ../../../LiteProject node_modules/@zakkster/lite-project
ln -s ../../../LiteStore node_modules/@zakkster/lite-store
ln -s ../../../LiteLeak node_modules/@zakkster/lite-leak
ln -s ../../../LiteGCProfiler node_modules/@zakkster/lite-gc-profilerRun the gate with --preserve-symlinks so Form.js and the harness resolve the
same lite-signal (a duplicate instance would make the witnesses vacuous):
node --expose-gc --preserve-symlinks test/torture.mjsEdge cases pinned down
dispose()is idempotent. Calling it twice is safe; the second call is a no-op.reset()clearssubmitErrortoo. A pristine form is fully pristine -- errors, touched flags, submit attempts, and the last submit throw all reset in one batched write.- Structural bugs re-throw, operational errors flow to
submitError.ReferenceError/SyntaxErrorthrown insideonSubmitare logged viaconsole.errorand re-thrown.TypeError(which is whatfetch()rejects with on network failure) is captured as a validsubmitError. Same for any other Error subclass. setPathpreserves arrays. Writingusers.0.name = "Bob"on a form initialized with{ users: [{ name: "Ann" }] }keepsusersan Array. We test that we don't accidentally overwrite it with{}because the path's numeric key is detected.- Lazy field creation works. Calling
form.field("undeclaredPath")on a field that wasn't ininitialValues/validators/fieldOptscreates it on the spot withundefinedas its initial value. Use this for forms with a variable shape -- though for known fixed shapes, declaring them ininitialValuesis faster. setValuesbatches. AsetValues({ a: 1, b: 2, c: 3 })call triggers exactly one re-evaluation ofisValid, not three.- Checkbox bridge works out of the box.
props().onInputdetectsev.target.type === "checkbox"and reads.checkedinstead of.value. - A
registryoption scopes everything. If you pass acreateRegistry()handle, every signal/computed lives in that registry. The default global registry stays untouched. Bind with that registry'seffectto wire to your renderer.
What this is not
- Not a renderer. lite-form gives you state; you decide how to render. Pair
with
@zakkster/lite-signal-dom,@zakkster/lite-element, vanillaaddEventListener, or your framework of choice. - Not a fetch/debounce layer.
validatorsAsyncsequences YOUR promises (last-write-wins, strict-false while pending) -- lite-form owns no timers and no transport. Debounce belongs to the caller via@zakkster/lite-debounce(see Async validation). - Not a field-array helper. Dotted paths work for nested objects and
arrays, but if you need
<FieldArray>-style adds/removes/reorders with preserved field identity, that's a future@zakkster/lite-form-fieldspackage. - Not a schema library. Bring your own (Zod / Yup / Valibot / hand-rolled).
lite-form just calls your
validatefunction once per change. - Not a wizard / multi-step form runtime. A wizard is multiple forms
composed together. Build it with two
createForm()calls and your own navigation state.
Browser / runtime support
| target | works | notes |
|------------------|:-----:|--------------------------------------------|
| Node >= 14 | yes | ES2015 baseline; lite-form calls no structuredClone |
| Chrome / Edge | yes | All evergreen versions |
| Firefox | yes | All evergreen versions |
| Safari >= 10 | yes | ES2015 baseline; own-walk snapshot, no structuredClone |
| Deno / Bun | yes | ESM + standard JS only |
ESM only. No CJS build. If you need CJS, bundle through esbuild/rollup.
Peer dependency
"peerDependencies": {
"@zakkster/lite-signal": "^1.5.0",
"@zakkster/lite-project": "^1.4.1"
}Both are always required. @zakkster/lite-project (~7 KB minified, 958 lines)
is the projection engine the value core rides in BOTH modes -- the import is
static, so it must be installed even if you never pass source (default mode
projects the detached baseline through it; engine mode projects your live
source). lite-form is ~1,565 lines on top of the two.
FAQ
Q: Is field() eager or lazy?
Eager by default: fields are allocated from initialValues + validators +
fieldOpts during createForm(), which is right for the known, bounded field
set most forms have. Lazy field() for an undeclared path is also fully
supported and safe: a lazy field allocated while a tracking context is live is
created inside the form's own registry.createRoot(), so its nodes belong to
the form, not the calling effect, and survive that effect's re-runs. This needs
lite-signal >= 1.5.0 (its createRoot detaches ownership); on older cores a
lazy field created inside a render effect would be torn down on the effect's
next run.
Q: Can I use field() for paths not in initialValues?
Yes. field("undeclared") creates the field lazily on first access. It just
won't have a per-field validator unless you also declared one. Useful for
forms with variable shape -- but if the shape is fixed, declaring up front is
slightly faster (no Map lookup miss).
Q: How do I integrate Zod / Yup / Valibot? Adapter is 4 lines. See Schema (Zod / Yup). Your library's parse function runs once per change inside lite-form's hoisted computed.
Q: What's the difference between error() and rawError()?
error() is reveal-gated by validateOn -- null if the field isn't yet
revealed. rawError() is always live. Use error() to drive your DOM display,
rawError() for anything that needs true validity (the form-level isValid
already wraps this for you).
Q: Why does validate return a flat path map instead of nested errors?
Flat keys make the field-side formErrors[path] lookup O(1) and structurally
cutoff-friendly. Wrap your schema's error shape in your adapter; Zod's
issues[i].path.join(".") is what most folks land on.
Q: I want to track value changes outside of effects (e.g. autosave debounce).
Use value.subscribe(fn) -- it's a real lite-signal subscribe; returns an
unsubscribe function. Or wrap it in an effect with your debounce. lite-form
doesn't ship a debounce helper because @zakkster/lite-debounce
already does (published; or setTimeout + clearTimeout if you'd rather not add a dep).
Q: How do I dispose a form?
Call form.dispose(). Every signal and computed is freed; the pool returns to
baseline. It's idempotent -- safe to call twice. If you're scoping a form to a
component lifecycle, register form.dispose as the cleanup.
Q: Why is submitAttempted writable?
So you can force-reveal errors without actually calling submit(). Useful if
you have an "are you sure?" preview step that needs to highlight invalid
fields before the actual submit.
Q: I called submit() twice and my server got two requests. Bug?
Working as designed -- submit() is not internally deduped. lite-form holds
no in-flight lock; concurrent calls fire onSubmit concurrently. See
Double-submit defense
for the three patterns (button-disable, @zakkster/lite-throttle, or an
isSubmitting.peek() guard). If you only have a button, disabling it on
isSubmitting() is enough; if submit() is reachable from a hotkey or
multiple handlers, throttle is the safer answer.
Q: How do I do setFieldError("username", "already taken") from my
server response?
You don't -- lite-form has no imperative error-setter. Use the
external-signal pattern:
hold the server error in a signal(), read it from your per-field validator,
flip it when the server responds. The validator re-runs automatically (the
signal read is tracked). Disposal is automatic; race conditions don't exist.
It ends up being less code than a setFieldError API would be -- see the
section for a full example.
License
MIT (c) Zahary Shinikchiev
The @zakkster stack
- @zakkster/lite-signal -- the reactive primitives this all builds on
- @zakkster/lite-project -- the projection engine the value core rides (both modes)
- @zakkster/lite-element -- Custom Elements with state that survives reparents
- @zakkster/lite-time -- drift-corrected wall-clock cadence
- @zakkster/lite-form -- this package
