@structyl/forms
Headless, schema-driven forms — a from-scratch chainable validator, a useForm engine, and Form/Field components on structyl’s accessible primitives. No react-hook-form, no zod, no extra dependencies.
Installation
pnpm add @structyl/formsQuick start
Define a schema with the v builder, wire it into useForm, and render fields. Labels, ARIA, and validation messages are handled by the accessible Form.* primitives.
Login form
Validation modes
mode controls when validation runs: onSubmit (default), onBlur, onChange, or all. State like isValid and isDirty updates reactively.
Validate on change
Validators
Every builder is chainable and immutable. Methods take an optional custom message; .optional() short-circuits empty values as valid.
| Builder | Methods | Description |
|---|---|---|
| v.string() | required, nonempty, min, max, length, email, url, uuid, numeric, pattern, oneOf, startsWith, endsWith, includes, trim, toLowerCase, toUpperCase | String constraints + transforms. |
| v.number() | required, finite, min, max, between, int, safe, positive, negative, nonnegative, nonpositive, multipleOf, step, coerce | Numeric constraints. |
| v.boolean() | required, isTrue, isFalse, coerce | Boolean checks (e.g. accept terms). |
| v.date() | required, valid, min, max, after, before, coerce | Date constraints. |
| v.array(item?) | required, nonempty, min, max, eachItem | List constraints; pass an item validator for eachItem(). |
| v.object(shape) | required, shapeValid | Nested object schemas. |
| v.custom(fn) | — | Plain-function escape hatch; receives (value, ctx) with ctx.values for cross-field rules. |
Cross-field validation
const schema = {
password: v.string().required().minLength(8),
confirm: v.custom((value, ctx) =>
value === ctx.values.password || 'Passwords must match'
),
};Defaults, null & transforms
Every validator supports these value options. .default() and .coerce()/.transform() fill or convert the value on read (form.values) and on submit — so empty inputs become real values.
| Option | Description |
|---|---|
| .optional() | Empty (‘’ / undefined) values skip validation. |
| .nullable() | null is allowed (independent of optional). |
| .default(value) | Fills empty values on read (form.values) and submit. Accepts a value or factory. |
| .coerce() | Convert raw input to the target type (string→number/boolean/date) before validating. |
| .transform(fn) | Map the value before it is stored/validated. |
| custom message | Every rule takes an optional last-argument message, e.g. .email(‘Invalid email’). |
const schema = {
// empty → 'user'; on read and submit
role: v.string().default('user'),
// native input gives a string — coerce to a number, then validate
age: v.number().coerce().int().min(18, 'Must be 18 or older'),
// null allowed; otherwise must be a valid URL
website: v.string().url().nullable(),
// trim + lowercase before storing
username: v.string().trim().toLowerCase().minLength(3),
};Configurable email
email() takes a message or an options object — control the TLD requirement, display-name form, custom regex, and domain allow/block lists.
v.string().email(); // default RFC-lite check
v.string().email('Enter a valid email'); // custom message
v.string().email({ requireTld: false }); // allow ada@localhost
v.string().email({ allowDisplayName: true }); // "Ada <ada@x.com>"
v.string().email({ blocklist: ['mailinator.com'] }); // reject disposable domains
v.string().email({ allowlist: ['company.com'] }); // only this domain
v.string().email({ pattern: /your-regex/ }); // bring your ownCustom inputs
Use <Controller> (or the useField hook) to bind non-native inputs — Select, DatePicker, Combobox, Checkbox — with a fully controlled value/onChange.
import { Controller } from '@structyl/forms';
import { Select } from '@structyl/styled';
<Controller name="country" render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} />
)} />Dynamic lists (useFieldArray)
useFieldArray manages repeatable fields with stable keys. It returns fields plus append, prepend, insert, remove, swap, move, update, and replace. Use each field.id as the React key.
Editable list
Multi-step wizards
The form store holds every step’s data the whole time, so moving between steps never loses input. Validate just the current step with form.trigger([...fields]), and survive a refresh with useFormPersist.
Stepper
Watching & reading values
useWatch(name) subscribes to one field and re-renders only when it changes — ideal for conditional fields. form.getValues() reads imperatively without subscribing.
import { useWatch } from '@structyl/forms';
// Re-renders only when "country" changes
const country = useWatch('country');
return country === 'US' ? <StateSelect /> : null;
// Imperative reads (no re-render)
form.getValues('country');
form.getValues(); // all valuesExternal schemas (zod / yup)
Prefer zod or yup? Bring your own — the adapters convert any external schema into the resolver useForm accepts. No dependency is added by structyl.
import { useForm, zodResolver } from '@structyl/forms';
import { z } from 'zod';
const schema = z.object({ email: z.string().email() });
const form = useForm({ schema: zodResolver(schema) });
// also: yupResolver, standardSchemaResolver (valibot, arktype, …)useForm API
| Member | Description |
|---|---|
| values / errors / touched | Current reactive state. |
| isValid / isDirty / isSubmitting | Derived flags. |
| submitCount | How many times submit ran. |
| register(name, opts?) | Binding for native inputs (name/onChange/onBlur/ref). |
| handleSubmit(onValid?, onInvalid?) | Returns a form onSubmit handler. |
| setValue(name, value, opts?) | Set a field; optionally validate/touch. |
| setError / clearErrors | Imperative error control (e.g. server errors). |
| reset(next?) | Reset to initial (or new) values. |
| validate(names?) / trigger(names?) | Validate the whole form, one field, or a subset (a wizard step). |
| getFieldState(name) | { value, error, touched, dirty, invalid }. |
| getValues(name?) / watch(name?) | Read values imperatively without subscribing. |
| setFocus(name) | Programmatically focus a registered field. |
| dirtyFields / isValidating | Per-field changed map; true while async validation runs. |
SSR & accessibility
The validation engine is pure (no window/document), so schemas run on the server too. <Form> renders structyl’s accessible Form.Root, wiring labels, aria-invalid, and aria-describedby for you. The store is backed by useSyncExternalStore for concurrent-safe, slice-level subscriptions.