The README is the reference: every call, every argument, every deliberate difference from the Go side. This is the other half — one form, from the first render to the second save, in the order a UI actually meets the library.
The schema throughout is testdata/registration_schema.json, the event
registration fixture both test suites run against, so every output below is real
rather than illustrative. It has one of everything: a toggleable group, a
collapsible one, fields that appear only under a condition, a repeating section
with a secret in each row, a read-only amount, a masked phone number and a
transient password confirmation.
Two working front ends are in
examples/: the
same form drawn by React and by Vue, sharing examples/shared/form.ts. Where
this document says "the renderer", that is what it means.
start-up load(schemaJSON) once, at module scope
every render compiled.visible(doc) what to draw
every change compiled.validate(doc) what is wrong, and the payload
first save POST validate().values the checker's output, not the inputs
reopen GET → a redacted document masks and secrets are not in it
every change compiled.diff(loaded, cur) only what the user touched
later saves PUT the patch the server runs apply()
The library holds no state. Every call takes the document and returns something new, so a form keeps exactly one piece of state — the document — and derives everything else from it on the way to the DOM.
import { load } from '@brobridge/dyfields'
const schema = await (await fetch('/api/forms/registration')).json()
export const compiled = load(schema)load() throws if the schema itself does not make sense — an unresolvable
reference, a visibility cycle, a mask on a field that can never be masked. That
is a bug in the schema rather than in the user's input, so it belongs in the
path that can still refuse to start. In a browser that means it fails on the
first render instead of quietly skipping a rule for the rest of the session.
The compiled schema is immutable and holds no per-document state. Keep one per schema at module scope; never build it inside a component.
If you would rather show the problems than throw:
import { validateSchema } from '@brobridge/dyfields'
const problems = validateSchema(schema) // [] when it is fineconst doc = { values: {}, secrets: {} }
const { groups, fields } = compiled.visible(doc)groups and fields are plain booleans keyed by path:
groups // { applicant: true, attendance: true, accommodation: true,
// companions_group: true, payment: true, account: true }
// the five fields that start out hidden
Object.entries(fields).filter(([, on]) => !on).map(([k]) => k)
// [ 'student_id', 'room_type', 'nights', 'special_request', 'tax_id' ]student_id and tax_id are hidden because their visible_when does not hold
yet. room_type, nights and special_request are hidden because they sit in
a toggleable group whose toggle defaults to false.
The renderer's job stops at reading those booleans:
for (const g of schema.groups) {
// The toggle of a toggleable group is drawn even when the group is closed --
// it is what opens it. It is not in g.fields: the schema declares it once,
// under `toggle`, and the library adds it to the document as a boolean.
if (g.toggle) drawToggle(g.toggle)
if (!groups[g.key]) continue
for (const f of g.fields) {
if (!fields[f.name]) continue
drawField(f)
}
}There is no per-field if anywhere in the two examples, and that is the point:
the browser and the server decide visibility from the same declaration, so they
cannot disagree about which fields the document is even supposed to have.
Group modes are a rendering instruction, not a rule:
mode |
What the renderer does |
|---|---|
always |
draw the section |
collapsible |
draw it with a disclosure header; the fields still exist when collapsed |
toggleable |
draw the toggle field as a checkbox; its value decides groups[key] |
A toggle is an ordinary boolean field in the document — need_accommodation
here — so it is set with the same setValue as everything else.
const { values, secrets, errors } = compiled.validate(doc)On the empty document:
errors.map((e) => `${e.path}: ${e.code}: ${e.reason}`)
// [ 'applicant: contact_required: provide at least one of email or phone',
// 'arrive_at: required: Arrival is required',
// 'full_name: required: Full name is required',
// 'leave_at: required: Departure is required',
// 'leave_at: leave_before_arrive: departure must be later than arrival',
// 'password: required: Password is required',
// 'ticket_type: required: Ticket is required' ]Every problem, in one pass — a checker that stops at the first one makes the
user fix and resubmit as many times as they made mistakes. Group-level rules
report against the group key (applicant), field-level ones against the field
path, so both have somewhere to go on screen:
const byField = new Map()
for (const e of errors) {
if (!byField.has(e.path)) byField.set(e.path, [])
byField.get(e.path).push(e)
}
// byField.get('full_name')?.[0].reason -> under the control
// byField.get('applicant') -> under the section headingNote the second leave_at error. Nothing has been typed yet, and the form is
already claiming departure is before arrival. That is honest — the checker
answers about the document it was given — but it is not what a user should see
on an untouched form. Every real form gates display, never the check:
const shown = errors.filter((e) => touched.has(e.path) || submitted)The demo deliberately shows everything from the first keystroke, because that is what makes the checker visible.
validate() also returns values and secrets, and those are the interesting
half — see §6.
Pick the student ticket and choose a company invoice:
doc.values.ticket_type = 'student'
doc.values.invoice_type = 'company'
const { fields } = compiled.visible(doc)
fields.student_id // true
fields.tax_id // trueNothing was subscribed and nothing fired. visible() is a pure function of the
document, so a form that recomputes it on every render gets conditional fields
for free. In React that is a useMemo on doc; in Vue a computed.
The rule that hid the field also governs it: student_id is required_when the
same condition, so it becomes required in the same instant it appears, without
the renderer knowing either fact.
The reverse matters more. When a field goes invisible its value is removed,
by validate() and by the server's apply() alike. Switch the invoice back to
personal and tax_id is gone from the payload — which is why the panel shows
the checker's output rather than the form's state.
{ "name": "companions", "type": "object_list", "item_label": "{name}",
"fields": [ { "name": "name", ... }, { "name": "id_number", "type": "secret" }, ... ] }Three things a renderer has to get right.
Rows carry their own identity. A row is a $id the client mints, not a
position:
compiled.requiresItemID('companions') // truetrue means something in the subtree is a secret, and secret keys are built
from $id, so a row without one has nowhere to put its password. Mint it when
the row is added — [A-Za-z0-9_-]{1,64}:
const $id = 'c_' + Math.random().toString(36).slice(2, 6)
doc.values.companions.push({ $id })Secrets live outside the tree, flat, keyed by that $id:
doc.secrets['companions.c_3b71.id_number'] = 'A234567890'Deleting a row therefore means deleting its secrets by prefix, which is what
removeEntry() in examples/shared/form.ts does. It is also the reason
identity is the $id: remove the middle row of a list keyed by position and
every password below it shifts onto the wrong person.
Errors point at both. path uses the index, because that is the row on
screen; itemPath uses the $id, because that is what the secret key is built
from:
{ path: 'companions[1].guardian', field: 'guardian', group: 'companions_group',
itemPath: 'companions.c_3b71', code: 'required',
reason: 'Guardian is required' }Conditions inside a row resolve inside that row — guardian is visible when
this row's is_child is true. To reach out to the form as a whole, the schema
says $.:
{ "name": "room_share", "type": "boolean",
"visible_when": { "field": "$.need_accommodation", "is_true": true } }So every row's "share a room" checkbox appears the moment the accommodation toggle goes on, and none of that reaches the component.
const { values, secrets, errors } = compiled.validate(doc)
if (errors.length > 0) return
await fetch('/api/registrations', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ values, secrets }),
})Post values and secrets, never doc. They are not the same object:
- defaults are filled in (
invoice_type: 'personal',need_accommodation: false), - values of hidden fields are removed,
transientfields are dropped after they are checked.
const out = compiled.validate(fullPayload)
'password_confirm' in out.secrets // false — it was compared, then droppedSubmitting the form's own state instead would send the confirmation to the
server, along with whatever a since-hidden field still happened to be holding.
The same applies to a document that arrives from elsewhere: run it through
validate() once before it becomes component state, which is what emptyDoc()
in examples/shared/form.ts does for a new form.
The server stores values and secrets, and answers with redact():
const pub = compiled.redact(stored)Two things the form has to render honestly.
secretsSet is a list of paths, not values. It is the difference between a
password box that says "already configured" and one that looks empty. Leave the
input blank, and submit '' for it — the patch rules read '' as "untouched".
A masked value is not the value. '*********5678' is what the field holds
now, and the user must not be invited to treat it as editable text. The examples
read the mask rules off the schema rather than sniffing for asterisks, and show
the control as held:
const masked = new Set()
compiled.walk((cf) => { if (cf.f.mask) masked.add(cf.path) }) // schema paths
// -> render "held by the server as *********5678", with an "edit" affordance
// that clears the control before the user typesAnd a caution that belongs on this page rather than in a footnote: running
validate() on a reopened document reports things that are not wrong.
compiled.validate(reopened).errors.map((e) => `${e.path}: ${e.code}`)
// [ 'companions[0].id_number: required',
// 'companions[1]: duplicate',
// 'companions[1].id_number: required',
// 'password_confirm: required',
// 'password_confirm: password_mismatch' ]Nothing is missing on the server. The form simply was not shown the ID numbers,
so it cannot see that they are present and distinct. A client cannot check what
it was never shown — so in an edit, the count on screen comes from the server's
answer to the patch (§9), not from validate().
const loaded = { values: structuredClone(pub.values), secrets: {} }
// ... the user changes the company name ...
const patch = compiled.diff(loaded, current){ "values": { "company": "Brobridge Inc." } }That is the whole request. Every other field is absent, which the server reads
as "unchanged" — including phone, which the form only ever held as
'*********5678'.
This is load-bearing, not an optimisation. Nothing marks a masked value as masked; it is a string like any other. A form that PUTs the whole document back does this:
compiled.apply(stored, { values: { phone: loaded.values.phone } }).values.phone
// '*********5678' — the mask is now the stored value, and no rule can undo itThere is no second protection, deliberately: apply() cannot reject what it
cannot recognise. Sending a patch is the protection.
Rows behave the same way. Edit one companion, delete another, add a third:
{
"values": {
"companions": [
{ "meal": "normal", "$id": "c_8f2a" },
{ "$id": "c_9c04", "name": "Ah-Bao Lin", "meal": "normal", "is_child": false },
{ "$id": "c_3b71", "$deleted": true }
]
},
"secrets": { "companions.c_9c04.id_number": "B123456789" }
}An untouched row contributes nothing, an edited one contributes only its changed
fields plus its $id, a deleted one contributes $deleted, and the new row
carries its secret. After apply() the server holds two companions and
c_3b71's ID number is gone with its row.
diff() never clears a secret on its own. A form never reads a secret back, so
"I do not have it" and "delete it" cannot be told apart from the values alone,
and only one of the two is recoverable if the guess is wrong. Clearing a secret
stays something the caller says outright — { secrets: { password: null } }.
apply() belongs to whoever holds the stored document — the Go server in most
deployments, this library in a Node one. The browser calls it only to show
what the server would answer, which is what the examples do; the authority is
still the process that persists the result.
It merges the patch into the document the form was never given, then checks the result:
const next = compiled.apply(stored, patch)
if (next.errors.length > 0) return respond(422, { errors: next.errors })
persist(next) // the checker's output again, not anything you assembledTwo things only exist here.
readonly is enforced only in apply(), because rejecting a change needs
a baseline to compare against:
compiled.apply(stored, { values: { amount: '1.00' } }).errors
// [ { path: 'amount', code: 'readonly',
// reason: 'Amount due is read-only and cannot be changed' } ]transient fields are the form's, and only the form's. apply() reports
nothing about one, or about a rule that reads one:
compiled.apply(stored, { values: { company: 'X' } }).errors // []Without that, password_confirm — required whenever a password is set, and
required to equal it — would fail on every unrelated update, because the stored
document structurally cannot contain a confirmation. A server cannot check what
it was never sent, the mirror image of §7. compile() works out which rules
those are once, so no backend has to remember.
So an edit screen shows the server's errors plus its own transient ones:
const shown = [...server, ...form.filter((e) => transient.has(e.field))]which is the whole body of mergeErrors() in examples/shared/form.ts. Note
that the server's half needs no filtering — apply() already reports nothing
about a transient field. All this adds is the half only the form can supply.
Turning the accommodation toggle off produces a one-key patch:
{ "values": { "need_accommodation": false } }and takes five values with it, because those fields stop existing:
compiled.impactOf(stored, patch)
// [ 'companions.c_3b71.room_share', 'companions.c_8f2a.room_share',
// 'nights', 'room_type', 'special_request' ]impactOf() answers "what would this throw away" before it happens, which is
what a "this will clear 5 fields, continue?" dialog is made of. It is the one
call with no equivalent anywhere else in the library.
The two examples keep one piece of state and derive the rest. React:
const [doc, setDoc] = useState(() => emptyDoc(compiled))
const result = useMemo(() => compiled.validate(doc), [compiled, doc])
const vis = useMemo(() => compiled.visible(doc), [compiled, doc])
const patch = useMemo(
() => (saved ? compiled.diff(saved.loaded, { values: result.values, secrets: result.secrets }) : null),
[compiled, saved, result],
)
function setValue(path, v) {
setDoc((d) => ({ ...d, values: v === undefined ? delIn(d.values, path) : setIn(d.values, path, v) }))
}Vue, the same contract:
const doc = ref(emptyDoc(compiled))
const vis = computed(() => compiled.visible(doc.value))
const result = computed(() => compiled.validate(doc.value))Replace the document rather than mutating it — the derived values are cheap,
and a mutation that skips them puts the screen and the checker out of step. The
three operations both apps expose are setValue, setSecret and removeEntry;
everything else on screen is a function of doc.
The server remains the authority. Everything here exists to make the form honest
while the user is still typing: the same schema, the same rules, the same codes,
so the answer does not change when they press submit. It does not replace the
check that happens afterwards, and readonly is the standing proof — one rule
the browser structurally cannot enforce.
{ "values": { "full_name": "Mei-Ling Chen", "phone": "*********5678", // mask: { keep_tail: 4 } "special_request": "*********************************", // mask: "all" "companions": [ { "$id": "c_8f2a", "name": "Xiao-Ming Wang", ... } ], ... }, "secretsSet": [ "companions.c_3b71.id_number", "companions.c_8f2a.id_number", "password" ] }