structyl
Package

@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

bash
pnpm add @structyl/forms

Quick 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

valid: true · dirty: false

Validators

Every builder is chainable and immutable. Methods take an optional custom message; .optional() short-circuits empty values as valid.

BuilderMethodsDescription
v.string()required, nonempty, min, max, length, email, url, uuid, numeric, pattern, oneOf, startsWith, endsWith, includes, trim, toLowerCase, toUpperCaseString constraints + transforms.
v.number()required, finite, min, max, between, int, safe, positive, negative, nonnegative, nonpositive, multipleOf, step, coerceNumeric constraints.
v.boolean()required, isTrue, isFalse, coerceBoolean checks (e.g. accept terms).
v.date()required, valid, min, max, after, before, coerceDate constraints.
v.array(item?)required, nonempty, min, max, eachItemList constraints; pass an item validator for eachItem().
v.object(shape)required, shapeValidNested object schemas.
v.custom(fn)Plain-function escape hatch; receives (value, ctx) with ctx.values for cross-field rules.

Cross-field validation

tsx
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.

OptionDescription
.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 messageEvery rule takes an optional last-argument message, e.g. .email(‘Invalid email’).
tsx
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.

tsx
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 own

Custom inputs

Use <Controller> (or the useField hook) to bind non-native inputs — Select, DatePicker, Combobox, Checkbox — with a fully controlled value/onChange.

tsx
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

AccountProfileAbout

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.

tsx
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 values

External 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.

tsx
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

MemberDescription
values / errors / touchedCurrent reactive state.
isValid / isDirty / isSubmittingDerived flags.
submitCountHow 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 / clearErrorsImperative 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 / isValidatingPer-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.