Forms & Validation
@pyreon/form is signal-based form management: every field's value, error, touched, and dirty are independent signals, so a keystroke patches only that field's bound DOM. Validation runs on blur by default (so users aren't scolded mid-keystroke), with sync, async, and schema adapters.
When to use it
Any form beyond a single uncontrolled input — login, signup, settings, multi-step wizards, dynamic arrays.
You want schema validation (Zod / Valibot / ArkType / Pyreon's
s) wired to fields.
When not to use it
A single input whose value you read on submit — a plain
signal()+onInputis fine.
Composable fields
Define fields once, infer the form shape from them:
// @check
import { field, useForm, Form, useField, Submit } from '@pyreon/form'
const email = field('email', '', (v) => (v.includes('@') ? undefined : 'Invalid email'))
const password = field('password', '', (v) => (v.length >= 8 ? undefined : 'Too short'))
function LoginForm() {
const form = useForm({
fields: [email, password],
onSubmit: (values) => console.warn('submit', values),
})
return (
<Form of={form}>
<EmailField />
<Submit>Log in</Submit>
</Form>
)
}
function EmailField() {
const f = useField<string>('email') // reads the form from <Form> context
return (
<div>
{/* register() spreads value + onInput + onBlur(→setTouched) + disabled/readOnly */}
<input {...f.register()} />
{() => (f.showError() ? f.error() : '')}
</div>
)
}<Submit> auto-disables during submission. useField('name') reads the nearest <Form> / <FormProvider> — no prop drilling. register() is the canonical input binding; for custom control bind manually with f.value / f.setValue / f.setTouched.
Validation that gates the error on blur, live:
Disabled and read-only fields:
Schema validation
import { useForm } from '@pyreon/form'
import { zodSchema } from '@pyreon/validation'
import { z } from 'zod'
const form = useForm({
initialValues: { email: '', age: 0 },
schema: zodSchema(z.object({ email: z.string().email(), age: z.number().min(18) })),
onSubmit: (v) => {},
})Standard-Schema validators (Zod 3.24+, Valibot 1.0+, ArkType 2.0+) work through the same adapter. For metadata-driven fields and reactive parsing, see @pyreon/validate (withField, parseReactive, formatErrors).
Arrays, watching, server errors
Arrays —
useFieldArray()givesappend/prepend/insert/remove/update/move/swap/replace. Always render with<For each={items()} by={(i) => i.key}>—.keyis a stable monotonic number, not the index.Watching —
useWatch(form, 'name')for one field;useFormState(form, selector?)forisValid/isDirty/isSubmitting/errors, narrowed by a selector so a submit button doesn't re-render on unrelated changes.Server errors —
form.setFieldError(name, msg)/form.setErrors({...})display immediately (they don't touchtouched).
Common pitfalls
A signal read in
initialValues.initialValues: { name: user() }snapshots the signal once and never updates. Pass the plain value, or set it later withform.setFieldValue. (@pyreon/lint'sno-signal-in-form-initial-valuesflags this.)Ternary short-circuit hiding error tracking.
{() => touched() ? error() ?? '' : ''}only subscribes toerroroncetouchedis true — a latererror.set()in the same batch is missed. Read both into consts first:{() => { const t = touched(); const e = error(); return t ? e ?? '' : '' }}.onChangeinstead ofonInput. UseonInputfor keystroke-by-keystroke updates (native DOM event).