@modyra/angular
v0.4.0
Published
Type-safe Angular forms built entirely on native Signals.
Downloads
395
Maintainers
Readme
Modyra
Model once. Render anywhere. A framework-agnostic, type-safe form engine with native bindings for Angular, React, Vue and Lit — one shared core, one shared headless widget layer, one shared theme package.
- No framework runtime, no RxJS — form state is plain signals and
computeds - Compile-time checked field bindings:
form.f.email— typos don't compile - Sync, async (debounced, cancellable, cross-field) and form-level validation
- Typed field arrays (
array()) for repeatable rows — push/insert/remove/move - Drafts (autosave/restore), undo/redo, minimal-patch change tracking, devtools
- Headless core or accessible ready-made controls — your design system or ours
- Incremental adoption paths — e.g. Reactive Forms interop (
mdyCva) on Angular
Packages
| Package | What it is | UI layer | Peer deps |
| :---------------------------------------------------- | :------------------------------------------------------------------------------------------------------- | :------------------------------------------------------- | :--------------------- |
| @modyra/core | Framework-agnostic form engine: typed field trees, arrays, validation, drafts, undo/redo, i18n utilities | headless | — |
| @modyra/widgets | Headless widget controllers + universal interaction/accessibility contract | headless | — |
| @modyra/angular | Angular binding on native signals | full renderer catalog, themes, devtools, wizard, interop | @angular/* ≥21 |
| @modyra/react | React binding via useSyncExternalStore | headless — bring your own UI | react ≥18 |
| @modyra/vue | Vue binding on @vue/reactivity | headless — bring your own UI | @vue/reactivity ≥3.4 |
| @modyra/lit | Lit binding — ReactiveController | themable form elements | lit ≥3 |
| @modyra/solid | Solid binding on native signals + headless widgets | headless — bring your own UI | solid-js ≥1.8 |
| @modyra/preact | Preact binding — thin variant of the React adapter | headless — bring your own UI | preact ≥10.19 |
| @modyra/svelte | Svelte binding — vanillaReactivity() + a toStore() bridge to real Svelte stores + headless widgets (no examples/ entry yet) | headless — bring your own UI | svelte ≥4 |
| @modyra/zod | Framework-agnostic Zod adapter — schema-first typed forms | — | zod ≥3.25 |
| @modyra/standard-schema | Standard Schema adapter — one adapter for Zod, Valibot, ArkType and every v1 vendor | — | — |
| @modyra/styles | CSS themes (default, material, ios, ionic, base) for every adapter | themes | — |
Every binding is a first-class citizen over the same engine: pick the one for your framework, keep everything else identical.
The engine in 60 seconds (framework-agnostic)
Everything below runs in plain TypeScript — Node, a CLI, a worker, a unit test, or any of the four framework adapters. No framework in sight:
import { createForm, field, group, required, email, min } from "@modyra/core";
const form = createForm({
email: field("", [required(), email()]),
age: field<number | null>(null, [min(18)]),
address: group({ city: field("Rome"), zip: field("") }),
});
form.f.email.set("not-an-email");
form.f.email.errors(); // ["Enter a valid email address"]
form.getValue().address.city; // "Rome" — fully typed, typos don't compileValidators are factories, not values: write
required(), notrequired(value-style-validator muscle memory trips here — the resulting TS error is easy to misread). Validation errors come back as arrays of message strings (["Name taken"]), not{ required: true }keyed objects. To stop at the first failing validator instead of collecting all of them, usecomposeFirst()in place ofcompose().
Real-world scenarios, handled by the engine
These are the cases that make form code rot in production — nested data, repeatable rows, server round-trips, refreshes, "apply my changes only". Each one below is complete and runnable in plain TypeScript.
1. Checkout: nested groups, repeatable line items, a coupon checked server-side
An order form with an address group, a typed array of line items, a coupon validated against the server (re-checked automatically when the country changes), and server errors routed back to fields on submit.
import {
createForm,
field,
group,
array,
required,
min,
pattern,
crossField,
serverValidator,
} from "@modyra/core";
const form = createForm(
{
country: field("IT"),
shipping: group({
city: field("", [required()]),
zip: field("", [required(), pattern(/^\d{5}$/, "5 digits")]),
}),
// Typed repeatable rows: form.f.items.rows(), push/insert/remove/move
items: array(
group({
sku: field("", [required()]),
qty: field<number>(1, [min(1)]),
}),
{ initial: [{ sku: "TSHIRT-BLK-M", qty: 2 }] },
),
coupon: field(
"",
[],
serverValidator(
async (code, ctx) => {
if (!code) return null; // optional field — skip the call
// ctx.form reads the rest of the form; ctx.signal cancels stale calls
const res = await api.coupons.check(
code,
ctx.form.fieldValue("country"),
{
signal: ctx.signal,
},
);
return res.valid ? null : "Coupon not valid for your country";
},
{
dependsOn: ["country"], // country flips → coupon re-validates itself
debounceMs: 400,
timeoutMs: 5000, // never a "pending" that lasts forever
},
),
),
},
{
validators: [
// Form-level rule over the whole typed value
crossField(["items"], (v) =>
v.items.length === 0 ? "Add at least one item to the order" : null,
),
],
},
);
form.f.items.push({ sku: "MUG-WHT", qty: 1 });
form.f.items.rows()[1].sku.errors(); // per-row, per-field errors
form.f.items.remove(0);
form.f.items.length(); // 1
form.getValue().items[0].qty; // number — a typo here is a compile error
const result = await form.submit(async (order) => {
const res = await api.orders.create(order);
if (!res.ok) {
// Server errors land on the matching field (or the form, with path: null)
return res.errors.map((e) => ({
path: e.field,
kind: "server",
message: e.message,
}));
}
});What the engine did for you: items.0.sku-style paths stay compile-checked;
state.valid() includes per-row validators and the form-level rule; the
coupon re-checks itself when country flips and aborts the previous HTTP
call when you keep typing; state.pending() covers every async run, so a
submit button bound to state.canSubmit() can't fire mid-validation.
2. A long insurance claim: survive refresh, undo mistakes, patch only what changed
A 40-field claim form. The user refreshes mid-way (drafts), deletes the
wrong paragraph (undo/redo), and your backend only wants the diff
(getChanges).
import { createForm, field, group, required, minLength } from "@modyra/core";
const form = createForm(
{
policyNumber: field("", [required()]),
incident: group({
date: field("", [required()]), // ISO yyyy-MM-dd
description: field("", [required(), minLength(30)]),
}),
iban: field("", [required()]),
},
{
// Autosave to localStorage every 500 ms of idle; restored on reload.
// Sensitive fields are masked in devtools and excluded from the draft.
draft: { key: "claim-form", exclude: ["iban"] },
history: true, // undo()/redo()
},
);
form.f.incident.description.set("The kitchen pipe burst and…");
form.undo(); // oops — bring the paragraph back
form.redo();
form.getChanges(); // { incident: { description: "The kitchen pipe…" } }
// → the minimal PATCH body, typedDrafts are versioned envelopes with a 7-day TTL: File/BigInt values are
refused, quota errors never crash the form, and __proto__-style paths in
a tampered localStorage entry are discarded at restore.
3. Schema-first: the backend already speaks Zod
The validation contract lives in one Zod schema shared with your API — the form derives fields, initial values, validators and cross-field rules from it, arrays included:
import { z } from "zod";
import { createZodForm } from "@modyra/zod";
const passengerSchema = z.object({
fullName: z.string().min(1, "Required"),
infant: z.boolean(),
});
const bookingSchema = z.object({
flight: z.string().min(1, "Pick a flight"),
passengers: z.array(passengerSchema).min(1, "At least one passenger"),
contact: z.object({
email: z.string().email(),
phone: z.string().optional(),
}),
});
const form = createZodForm(bookingSchema);
form.f.passengers.push({ fullName: "Ada Lovelace", infant: false });
// z.array() → typed field array; .min(1) → array-level validator gating submitzod is an optional peer (>=3.25, Zod 4 supported): apps that don't
use schemas never download it.
The same app, four frameworks
The engine is identical everywhere — only the reactive binding and the rendering idiom change. A complete checkout form (nested groups, typed array rows, cancellable server validation, submit with server errors) is implemented end-to-end, side by side, in:
- Angular —
mdyForm+ the UI catalog,@foroverrows() - React —
useMdyForm/useMdyField, array rows with.map() - Vue —
createVueFormon@vue/reactivity - Lit —
createLitForm+<mdy-*-field>custom elements
Adapter recipes, the four-primitive reactive contract and the Astro note: Multi-framework architecture.
Coming from Angular Reactive Forms?
This library trades ecosystem maturity for compile-checked field paths,
signal-based state (zoneless-friendly, no RxJS — no runtime dependency, no
Observables in the public API, none used internally) and built-in
async/cross-field validation, typed arrays, drafts, undo/redo and devtools.
The /interop entry point (mdyCva, optional @angular/forms peer) covers
incremental adoption. Full, honest comparison:
Compared with Reactive Forms.
Layers
Typed API (mdyForm) Declarative API Dynamic JSON config
\ | /
Shared Form Engine (@modyra/core)
|
Headless widget layer (@modyra/widgets)
|
Angular ─ React ─ Vue ─ Lit (one native binding each)
|
Headless integrations or UI catalogs + @modyra/styles themesThe form engine — typed field trees, arrays, validation, drafts, undo/redo —
lives in @modyra/core, a zero-dependency package that
runs in plain Node. @modyra/widgets adds headless
widget controllers and the interaction/accessibility contract shared by
every renderer. Each adapter implements the same four-primitive reactive
contract on its framework's native reactivity: Angular signals,
@vue/reactivity, React's useSyncExternalStore, Lit's ReactiveController.
Angular entry points
| Import | Contents | Extra peer deps |
| :------------------------ | :---------------------------------------- | :------------------------------- |
| @modyra/angular | Full bundle: adapter + UI + tools | — |
| @modyra/angular/adapter | Headless Angular adapter layer only | — |
| @modyra/angular/ui | UI primitives and built-in renderers only | — |
| @modyra/angular/zod | mdyFormFromSchema() | @modyra/zod + zod (optional) |
| @modyra/angular/interop | mdyCva for Reactive Forms | @angular/forms (optional) |
Documentation
The full index lives in docs/README.md, also published
as a searchable site (npm run docs:build — site/, an
Astro/Starlight project generated from docs/; that directory stays the
single source of truth). The shortlist:
- Mental model — the state graph, field lifecycle, operation semantics
- Typed forms — schema, handles,
patch/getChanges, async validation, field arrays, undo/redo,mutate(), construction/activation (SSR, Strict Mode), drafts (read the security note), wizard, Zod - Schema adapters — Zod vs Standard Schema (Valibot, ArkType, …): which model, which trade-offs
- Server validation — one schema, two sides:
serverValidate()with Next.js/Express/Hono - Usage modes — declarative, explicit adapter, headless, validation semantics
- UI toolkit — renderer catalog, enterprise select, dynamic forms, CSS tokens
- AI-generated forms — LLM output →
parseDynamicFields()→ render: the safe pipeline + system prompt template, including the Contract v2 layout/rules extension - Writing a reactivity adapter · capability matrix — the
MdyReactivitycontract for building the next framework binding - DevTools — hotkey overlay, masking, production notes
- I18n — UI strings (en/it/de/fr/es), date/time value models, localized parsing
- Reactive Forms interop · Comparison · Troubleshooting
Rust SDK: sdk/rust/ — modyra-contract, a network-independent
crate for reading/writing/validating Dynamic Form Contract v2, plus runnable
reqwest and Axum examples (the Angular dynamic-form demo round-trips
against the Axum one). Shares its conformance fixtures with the TypeScript
implementation (spec/). Not published to crates.io yet.
Project policies: security · contributing · changelog
Compatibility and status
- Angular 21+ — the engine relies on stable signal APIs (
linkedSignal,effectsemantics, signal-based inputs/queries) shipped in recent majors; older majors are not tested and not supported. - React ≥18,
@vue/reactivity≥3.4, Lit ≥3,zod≥3.25 — each only for its own adapter package. - TypeScript strict mode; the library compiles with
strictandstrictTemplates. - Status: young library, actively developed, single maintainer.
npm testruns the whole matrix — core engine, every adapter, the widget layer and the Angular package (test:core,test:adapters,test:widgets,test:angularindividually) — plus a tree-shaking bundle check (npm run test:bundle), axe-core accessibility tests over the main Angular renderers (jest + jsdom, insidetest:angular) and a Playwright browser smoke test over the packaged Angular demo (npm run test:e2e, currently non-blocking in CI while it stabilizes); visual regression tests are still planned. Pin your version and read release notes.
Examples
Fastest way to try the library — no cloning, no workspace, each runs against the published npm packages:
Angular (
examples/stackblitz)
React (
examples/stackblitz-react)
Vue (
examples/stackblitz-vue)
Lit (
examples/stackblitz-lit)
Each is a real, verified, buildable Vite project (npm install && npm run
dev) with the same signup demo (schema validators, cross-field password
check, draft persistence, undo/redo, cancellable server-side check) — not
just a code snippet. Solid and Preact don't have one yet (tracked in
ROADMAP.md).
examples/angular is the full Angular demo app (typed, declarative, dynamic
and Zod sections over the whole renderer catalog) — one demo app per
framework, same engine.
examples/{react,vue,lit,preact,solid} implement the same signup form
(name + email, shared validators, agnostic devtools panel) so the adapters
can be compared side by side, with a runtime switcher across the shipped
themes (default, Material, iOS, Ionic) — React/Vue/Lit only; Preact/Solid
use a single theme for now. They import the built @modyra/* packages
from node_modules — the same artifacts users install, never the library
sources.
npm run demo:angular # Angular demo over the packaged build
npm run demo:react # http://localhost:4301
npm run demo:vue # http://localhost:4302
npm run demo:lit # http://localhost:4303
npm run demo:preact # http://localhost:4304Local development
pnpm install # workspace deps use the workspace: protocol — use pnpm
npm run build:packages # core + widgets + schema adapters + react/vue/lit + styles
npm run build:angular # the Angular package (kept as build:lib alias)
npm test # the whole matrix: core, adapters, widgets, Angular
npm run demo:angular # one demo per framework: demo:react / demo:vue / demo:litBrand
Logo, palette and typography live in
docs/assets/brand — three soft modules (the
adapters) around a shared negative space (the core).
License
MIT © Lorenzo Muscherà
