@modyra/core
v2.5.0
Published
Framework-agnostic type-safe form engine — typed field trees, sync/async/cross-field validation, drafts and undo/redo over a minimal reactive contract.
Downloads
780
Maintainers
Readme
@modyra/core
One form contract. Every framework. Any backend. The engine half of that contract: typed field trees and arrays, sync/async/cross-field validation, dirty/touched tracking, draft persistence, undo/redo and minimal-patch change tracking — with zero dependencies and no framework in sight.
npm install @modyra/coreimport { createForm, field, group, required, min } from "@modyra/core";
const form = createForm({
email: field("", [required()]),
age: field<number | null>(null, [min(18)]),
address: group({ city: field("Rome") }),
});
form.f.email.set("[email protected]");
form.f.email.errors(); // []
form.getValue().address.city; // typed — typos do not compileRuns in Node, CLIs, workers and plain unit tests.
Feature tour
Typed field arrays — repeatable rows with compile-checked paths:
import { array, field, group, min } from "@modyra/core";
const form = createForm({
items: array(group({ sku: field(""), qty: field<number>(1, [min(1)]) }), {
initial: [{ sku: "TSHIRT-BLK-M", qty: 2 }],
}),
});
form.f.items.push({ sku: "MUG-WHT", qty: 1 });
form.f.items.rows()[1].sku.errors();
form.f.items.move(0, 1);
form.getValue().items[0].qty; // numberCollections keyed by data — rows addressed by an entity id or a provisional key rather than by position, so a row survives sorting and filtering, and the controls of one row may be mounted apart:
import { field, group, min, record } from "@modyra/core";
const form = createForm({
lines: record(group({ name: field(""), qty: field<number>(1, [min(1)]) })),
});
form.f.lines.upsert("a3f9", { name: "Espresso", qty: 2 });
form.f.lines.cell("a3f9", "name").set("Ristretto"); // one control of one row
form.f.lines.rename("tmp:1", "77"); // keeps value, validity and touched
form.value().lines; // { a3f9: { name: string; qty: number } }A row exists because upsert declared it, never because something rendered
it: unmounting a control keeps the value, and validity belongs to the row.
Server-side async validation, done right — cancellable, cross-field, debounced, with timeout and preconditions:
import { field, serverValidator } from "@modyra/core";
coupon: field(
"",
[],
serverValidator(
async (code, ctx) => {
if (!code) return null;
const res = await api.check(code, ctx.form.fieldValue("country"), {
signal: ctx.signal, // aborted when the run is superseded
});
return res.valid ? null : "Coupon not valid for your country";
},
{ dependsOn: ["country"], debounceMs: 400, timeoutMs: 5000 },
),
);Drafts, history, minimal patches:
const form = createForm(schema, {
draft: { key: "checkout", exclude: ["iban"] }, // autosave/restore, TTL'd
history: true, // undo()/redo()
});
form.getChanges(); // → typed minimal patch for your PATCH endpointCross-field validation — form-level rules over the whole typed value,
attributed to fields or to the form itself (path: null):
import { crossField } from "@modyra/core";
createForm(schema, {
validators: [
crossField(["passwordConfirm"], (v) =>
v.password !== v.passwordConfirm ? "Passwords do not match" : null,
),
],
});The reactive contract
The engine is written against four primitives — signal, computed,
effect (with cleanup) and untracked — the common denominator of
fine-grained reactivity (Solid, Preact Signals, Vue, Angular Signals, and
the TC39 Signals proposal). It is not an Angular API: Angular is just
one binding of the contract.
import { createForm, vanillaReactivity } from "@modyra/core";
// Node / tests / workers: the bundled graph
const form = createForm(schema); // reactivity defaults to vanillaReactivity()A framework adapter passes its own implementation, so form state participates natively in the host's change detection. Which adapters exist, and how completely each implements the contract, is published in the reactivity capability matrix.
Security notes
- Drafts are versioned envelopes; expiry is opt-in via
ttlMs.File/Blob/BigIntvalues are refused, quota errors never crash the form, and prototype-pollution paths (__proto__& co.) in tampered storage are discarded. - The framework-agnostic devtools panel masks sensitive-looking paths and escapes every rendered value.
- Zero runtime dependencies, SSR-safe (no
window/documentaccess in the engine).
Documentation
License
MIT © Lorenzo Muscherà
