How it works
How @k8ordo/form works underneath. None of it is needed to use the package, but knowing why it is written this way makes the edge cases easier to reason about.
On this page
One schema, at work in three places
The schema is used twice, both times on the server: once to derive the fields, once to validate the submission. The browser only ever receives the JSON derived from it.
server formFields(schema) → { fields, arrays, rules, dropped }
↓ props (JSON)
client useForm(fields, state) → props for <form>, input for each field
↓ submit
server parseForm(schema, data) → typed data, or errors per fieldSo zod never enters the browser bundle. A schema can be written with zod or zod/mini; as long as nothing in the browser reads it, the choice does not change the bundle size.
The DOM holds the values
Values stay in the DOM and are never copied into React state. State holds only the five things the DOM cannot express:
- Which message to show for a field the browser has judged invalid
- Which server errors are still current
- The identity of each repeated row
- One dirty flag, read back from the DOM
- The row counts that adding or removing a row is measured against
Since nothing is copied, typing never re-renders, and both the browser’s reset and React’s reset after an action simply work.
Why it works without JavaScript
The constraints are attributes in the server-rendered HTML, so the browser checks input itself before JavaScript arrives.
Once JavaScript arrives, useForm sets noValidate and runs the same checks in zod’s wording. It is set from JavaScript, never written into the HTML, so without JavaScript the browser’s own checks stay on.
After a failed submission, the submitted values render as the fields’ defaultValue, so nothing typed is lost even without JavaScript.
Why the wording never drifts
For each kind of check, formFields hands the schema a value that fails only that check and keeps the message that comes back. The text shown in the browser is exactly the text zod produces on the server.
How required is decided
JSON Schema’s required means “the key is present”, but a form submits something for every control. So formFields emits required only when the schema rejects what the control submits when left empty.
What an empty control submits depends on the control: "" for text, false for an unchecked checkbox, nothing for a number, a file or a choice. parseForm hands the schema exactly the same values, so the two sides agree. Below, bio accepts an empty string and age may be left out, so neither gets required.
z.object({ bio: z.string() });
// bio: { name: 'bio', type: 'text' }
z.object({ title: z.string().min(1) });
// title: { name: 'title', required: true, type: 'text', minLength: 1 }
z.object({ age: z.coerce.number().optional() });
// age: { name: 'age', type: 'number', step: 'any' }Reporting what the browser cannot check
Some checks cannot be expressed as HTML attributes: a .refine() on the whole schema, a regex with flags. Rather than dropping them silently, formFields lists them in dropped.
Outside production it also prints the list with console.warn. Every one of them still runs on the server; the browser just does not check them.
A schema a form cannot express is an error
Every submitted value is a string, so z.number() or z.date() would make a field nothing can satisfy, and shapes such as z.record or tuples have no name to submit under. formFields throws on these, with the reason.
A form that silently discards what the person did, or one that can never validate, is a mistake to report, not something to paper over.