@pyreon/form
Signal-based form management — fields, validation, arrays, and context.
Every field on a Pyreon form is its own Signal<T> (one each for value, error, touched, dirty), so templates only re-run for the slice they read. Two complementary APIs: the classic useForm({ initialValues, validators, onSubmit }), and the composable field('name', default, validator) → useForm({ fields }) + useField('name') that infers FormState from a field array and skips prop drilling via <Form of={form}> / FormProvider. Pairs with @pyreon/validation for Zod/Valibot/ArkType schema integration.
Install
bun add @pyreon/form @pyreon/core @pyreon/reactivity
Quick start
import { useForm } from '@pyreon/form'
function LoginForm() {
const form = useForm({
initialValues: { email: '', password: '' },
validators: {
email: (v) => (!v.includes('@') ? 'Invalid email' : undefined),
password: (v) => (v.length < 8 ? 'Too short' : undefined),
},
validateOn: 'blur',
onSubmit: async (values) => {
await fetch('/api/login', { method: 'POST', body: JSON.stringify(values) })
},
})
return (
<form onSubmit={form.handleSubmit}>
<input type="email" {...form.register('email')} />
<input type="password" {...form.register('password')} />
<button type="submit">Login</button>
</form>
)
}
Composable fields + <Form> (recommended)
Define fields once, infer the form shape automatically, and read from context instead of prop drilling:
import { field, useForm, useField, Form, Submit } from '@pyreon/form'
const email = field('email', '', (v) => (!v.includes('@') ? 'Invalid' : undefined))
const password = field('password', '', (v) => (v.length < 8 ? 'Too short' : undefined))
const confirm = field('confirmPassword', '', (v, all) =>
v !== all.password ? 'Mismatch' : undefined,
)
const form = useForm({
fields: [email, password, confirm],
onSubmit: (values) => {
/* { email: string; password: string; confirmPassword: string } */
},
})
function EmailInput() {
const f = useField<string>('email')
return (
<>
<input {...f.register()} />
{() => (f.showError() ? <span>{f.error()}</span> : null)}
</>
)
}
;<Form of={form}>
<EmailInput />
<PasswordInput />
<Submit>Login</Submit>
</Form>
<Form of={form}> renders a <form> element, binds onSubmit to form.handleSubmit, and provides the form via context. <Submit> auto-disables during form.isSubmitting().
useForm(options)
| Option | Type | Description |
|---|---|---|
initialValues |
TValues |
Required when using the value-form. Drives field keys + types. |
fields |
FieldDefinition[] |
Required when using the composable form. Drives TValues via inference. |
onSubmit |
(values: TValues) => void | Promise<void> |
Submit handler — receives validated values |
validators |
Partial<Record<keyof TValues, ValidateFn>> |
Per-field validators; signature (value, allValues) => string | undefined |
schema |
SchemaValidateFn<TValues> |
Whole-form schema validator (from @pyreon/validation adapters) |
validateOn |
'blur' | 'change' | 'submit' |
When to validate. Default: 'blur' |
debounceMs |
number |
Debounce delay for validators (especially async) |
Returns FormState<TValues> with per-field Signals (value, error, touched, dirty), form-level signals (isSubmitting, isValidating, submitCount, isSubmitted, isSubmitSuccessful, submitError), computed accessors (isValid(), isDirty(), values(), getValues(name?), errors(), dirtyFields(), touchedFields()), and handlers (handleSubmit, register, validate, trigger, reset, setFieldValue, setFieldError, setErrors, clearErrors, resetField, getFieldState).
react-hook-form-parity accessors (all strictly typed against TValues):
trigger(name?)— validate one field, a subset (array), or — no argument — the whole form, on demand. Runs validators immediately (bypassingdebounceMs); returns whether the validated set is valid.getValues(name?)— one field's value (getValues('email')) or all (getValues()).dirtyFields()/touchedFields()— the changed / visited fields as a record ({ email: true }). Reactive.getFieldState(name)— a field's liveFieldState(same object asform.fields[name]).isSubmitted—Accessor<boolean>, true oncesubmitCount > 0.isSubmitSuccessful—Signal<boolean>, true only after the most recent submit'sonSubmitran without a validation failure or throw; cleared byreset().
// Bind text input:
<input {...form.register('email')} />
// Bind checkbox (boolean field):
<input type="checkbox" {...form.register('remember', { type: 'checkbox' })} />
// Bind number input (auto-parses to number):
<input type="number" {...form.register('age', { type: 'number' })} />
useField(name) / useField(form, name)
Extracts a single field's state with computed helpers (hasError, showError). Two overloads:
// Context form — reads form from <Form>/<FormProvider>. Accepts generic.
const f = useField<string>('email')
// Explicit form — pass a known FormState.
const f = useField(form, 'email')
Returns UseFieldResult<T>:
| Property | Type | Description |
|---|---|---|
value |
Signal<T> |
Field value |
error |
Signal<ValidationError> |
string | undefined |
touched |
Signal<boolean> |
True after first blur |
dirty |
Signal<boolean> |
True when value differs from initial |
hasError |
Computed<boolean> |
True when an error string exists |
showError |
Computed<boolean> |
True when touched() AND hasError() |
setValue |
(v: T) => void |
Programmatic set |
setTouched |
(b: boolean) => void |
Mark touched |
reset |
() => void |
Reset to field's initial value |
register |
() => FieldRegisterProps<T> |
Spreadable input props |
showError is the right gate for displaying error messages — it stays silent until the user has blurred at least once, even when validateOn: 'change'.
useFieldArray(initial?)
Dynamic arrays with stable monotonic keys for keyed rendering.
const tags = useFieldArray<string>(['typescript'])
tags.append('pyreon')
tags.prepend('signals')
tags.insert(1, 'reactive')
tags.move(0, 2)
tags.swap(0, 1)
tags.update(0, 'updated')
tags.remove(0)
tags.replace(['a', 'b', 'c'])
tags.values() // string[]
tags.length() // number
tags.items() // FieldArrayItem<string>[] — { key: number, value: Signal<T> }
Each item carries a key: number — monotonically increasing, assigned at insert time. Render with <For each={tags.items()} by={(i) => i.key}> so reordering / insertion preserves component identity (and input focus). Index-based keys defeat the stable-key design.
useWatch(form, name?)
Typed overloads for reactive field watchers:
const email = useWatch(form, 'email') // Signal<string>
const [first, last] = useWatch(form, ['firstName', 'lastName']) // tuple of Signals
const all = useWatch(form) // Computed<TValues>
Single-field form returns the underlying Signal<T> directly (no wrapper). Prefer the narrowest form — watching the whole form re-runs your effect on every field change.
useFormState(form, selector?)
Computed summary of form-level state. Pass a selector to narrow the tracked subset.
const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting && s.isDirty)
// canSubmit() updates only when those 3 booleans flip
FormStateSummary fields: isSubmitting, isValidating, isValid, isDirty, submitCount, isSubmitted, isSubmitSuccessful, submitError, touchedFields, dirtyFields, errors. Without a selector the computed re-derives on ANY summary field change — always pass a selector for UI-bound computeds.
FormProvider / useFormContext() / <Form> / <Submit>
<FormProvider form={form}>
<DeepInputs />
</FormProvider>
// Inside any descendant:
function DeepInputs() {
const form = useFormContext<{ email: string; password: string }>()
return <input {...form.register('email')} />
}
<Form of={form}> is sugar: renders <form onSubmit={form.handleSubmit}> + <FormProvider>. <Submit> reads form.isSubmitting() from context and auto-disables.
Pass the <TValues> generic on useFormContext — otherwise TypeScript infers Record<string, unknown> and field names lose type narrowing.
Server-side errors
const form = useForm({
initialValues: { email: '' },
onSubmit: async (values) => {
const res = await fetch('/api/register', { method: 'POST', body: JSON.stringify(values) })
if (!res.ok) {
const errors = await res.json()
form.setErrors(errors) // { email: 'Already registered' }
}
},
})
setFieldError / setErrors do NOT touch touched state — server errors display immediately regardless of blur status.
Schema validation
Pair with @pyreon/validation adapters. Per-field validators run first; schema errors merge after.
import { zodSchema } from '@pyreon/validation/zod'
import { z } from 'zod'
const form = useForm({
initialValues: { email: '', age: 0 },
schema: zodSchema(z.object({ email: z.string().email(), age: z.number().min(13) })),
onSubmit: async (values) => {
/* ... */
},
})
Devtools
import { formRegistry } from '@pyreon/form/devtools'
// WeakRef registry of live form instances — tree-shakeable.
Gotchas
validateOndefaults to'blur', not'change'— users aren't scolded mid-keystroke. Pair'change'withdebounceMsfor async validators.- Async validators are version-tracked — stale results are discarded if the user types faster than the validator resolves.
form.isValidatingistruewhile any field has a pending async validation; gate the submit button on it. - Mutating
initialValuesafter creation has no effect — they're read once at setup. UsesetFieldValuefor programmatic updates. form.fields[name].valueisSignal<T>— call it:form.fields.email.value(). Reading without calling captures the signal reference, not the value.handleSubmitcallspreventDefault()— wire it as<form onSubmit={form.handleSubmit}>or call with no argument for programmatic submit.schemaruns AFTER per-fieldvalidators— both error sources merge; a schema error can override a field-level error on the same key.FormProviderdoesn't support nesting — the inner shadows the outer. For multi-form pages use separate sibling providers.register()results are memoized per field+type combo — callingregister('email')twice returns the same object.useFormState(form)without a selector re-derives on every state change. Always pass a selector.- Don't pass a signal-read into
initialValues—initialValues: { name: user() }snapshots once. UsesetFieldValuereactively. Caught by the opt-in lint rulepyreon/no-signal-in-form-initial-values.
Documentation
Full docs: pyreon.dev/docs/form (or docs/src/content/docs/form.md in this repo).
License
MIT