Two front-ends, one schema, no per-field code: the same form drawn by React and by Vue,
both driven entirely by a dyfields schema. Both are TypeScript, and take their types from
the library's own declarations — a schema key that does not exist is a compile error rather
than an undefined on screen.
cd examples/react && npm install && npm run dev
cd examples/vue && npm install && npm run dev
Each opens a page with the form on the left and, on the right, the payload that a submit
would send — recomputed on every keystroke. Submit once and the demo keeps the document as
if a server had stored it and reopens the form on what a GET would hand back; from then
on the panel shows the patch an update would send instead. npm run typecheck checks
the types without building; npm run build does both.
| Where to look | |
|---|---|
| Drawing a form from a schema | Field.tsx / DyField.vue — one component, thirteen types, any depth |
| Which fields exist right now | visible() decides; the components never re-implement visible_when |
| What is wrong, all of it at once | validate().errors, keyed by path, shown under the control that caused it |
| Where a secret goes | doc.secrets['companions.c_3b71.id_number'], never inside values |
| What gets submitted | validate().values and .secrets, not the raw inputs |
| What is safe to log | redact() |
| What an update sends | diff(loaded, current) — only what the user actually changed |
| What the server answers | apply(stored, patch), on a document the form was never given |
| A value the form is not allowed to see | mask + redact(); the control says it is held, not empty |
Both examples pick between two schemas at the top of the page: the event registration
form (every group mode, a file upload, a decimal, a transient password confirmation, a
companion list whose rows read a field at the root of the form) and the data pipeline
form (two levels of object_list, secrets at both levels, a map, a json escape hatch,
and fields that appear only for the sink type you picked). Both schemas are the fixtures
the Go and JavaScript test suites already run against, imported from testdata/ rather
than copied, so what the demo draws cannot drift away from what the checkers are tested on.
examples/
shared/
schemas.ts the two demo schemas
form.ts path arithmetic, field-to-control mapping, input coercion, the shared
types, and store() -- the eight-line stand-in for a server
dom.ts event-target narrowing, in one place
styles.css one stylesheet, so the only difference between the two is the code
react/src/
App.tsx groups, the three group modes, the document and the ways to change it
Field.tsx one field; renders itself again for the rows of an object_list
Payload.tsx the right-hand panel
form-context.ts what every field needs, and the shape of it
vue/src/
DyForm.vue the document, provided to the tree
DyGroup.vue one group
DyField.vue one field; renders itself again for the rows of an object_list
DyPayload.vue
form.ts the same contract, as a typed InjectionKey
The two apps share the same model on purpose — the document is replaced rather than
mutated in both, and the three operations (setValue, setSecret, removeEntry) have the
same signatures — so the React and Vue versions can be read side by side. The one place
they differ is the reactive wrapper: React's context carries plain values, Vue's injection
carries a Ref and two ComputedRefs, and both say so in their types, so a component that
reads doc.values where it should read doc.value.values never reaches the browser.
The form asks; it never decides. Every rule in these examples comes out of
validate() and visible(). There is no per-field if in the components, and no rule is
written twice — which is the only way the browser and the server can agree about what is
valid.
The payload is the checker's output, not the inputs' contents. validate() returns a
document with defaults filled in, invisible fields removed and transient fields dropped.
Submitting that, rather than the raw form state, is what stops the form and the server
from disagreeing about what was sent. It also means a document loaded from a server should
be run through validate() once before it goes into component state — which is exactly
what emptyDoc() in shared/form.ts does for a new form.
A row's identity is its $id, not its position. Rows carry a client-generated $id,
secrets are keyed by it, and deleting a row deletes its secrets by prefix. That is why
removing the middle row of a list cannot shift the next row's password onto it.
A client cannot check what it was never shown. After the first submit the form holds
neither the secrets nor the masked values, so it is in no position to say whether the
record is complete — validate() on what it holds would report fields missing that are
not missing at all. In an edit the demo therefore shows apply(stored, patch): the
server's answer, on the merged document. The one exception is the transient fields, which
are dropped from the payload by design and so never reach the server at all; those the form
checks itself. The library draws that line rather than the demo: apply() reports nothing
about a transient field, because a server cannot check what it was never sent — so
mergeErrors() in shared/form.ts only has to add the form's own transient errors back,
and a real backend gets the other half for free.
- Errors before the user has typed anything. The demo shows every problem immediately, because that is what makes the checker visible. A real form usually waits until a field has been touched, or until submit, and keeps the full list for the summary panel.
- A real server. The demo's "server" is
store()inshared/form.ts: it keeps the document, hands back the redacted version, and pockets the masked values. Behind a real one the same three calls sit on the other side of the wire —redact()on the way out,apply()on the way in — and nothing in the components changes. impactOf()before a destructive save.impactOf(current, patch)lists the values a patch is about to throw away, which is what a "this will clear 3 fields, continue?" dialog is made of. The demo applies the patch without asking.