Form fields
The pattern
Define fields with field() (pure data — name + default + validator), compose them with useForm({ fields: [...] }), and read them from context with useField('name'):
// @check
import { field, useForm, useField, Form, Submit } from '@pyreon/form'
declare const api: { register(values: unknown): Promise<void> }
declare function PasswordInput(): JSX.Element
declare function ConfirmInput(): JSX.Element
// 1. Field definitions — pure data, no rendering opinion.
const email = field('email', '', (v) =>
!v.includes('@') ? 'Invalid email' : undefined,
)
const password = field('password', '', (v) =>
v.length < 8 ? 'Too short' : undefined,
)
const confirm = field('confirmPassword', '', (v, all) =>
v !== all.password ? 'Must match password' : undefined,
)
// 2. Compose — types inferred from the fields array.
const form = useForm({
fields: [email, password, confirm],
onSubmit: async (values) => {
// values is typed as { email: string; password: string; confirmPassword: string }
await api.register(values)
},
})
// 3. Components read the form from context — no prop drilling.
function EmailInput() {
const f = useField<string>('email')
return (
<div>
<input {...f.register()} />
{() => f.showError() && <span class="error">{f.error()}</span>}
</div>
)
}
// 4. Render. <Form> provides context + binds onSubmit. <Submit> auto-disables.
const RegisterForm = () => (
<Form of={form}>
<EmailInput />
<PasswordInput />
<ConfirmInput />
<Submit>Register</Submit>
</Form>
)Key rules:
field(name, default, validator?)carries the field name as a string-literal type — theuseFormgenerics infer the fullFormState<TValues>shape from the fields array. No manual type annotation needed.useField('name')reads the form from the nearest<Form>/<FormProvider>. AcceptsuseField<ValueType>('name')for generic narrowing.Validators are plain
(value, allValues) => string | undefined. Cross-field access is free — seeconfirmabove.f.showError()istouched() && hasError()— gated on blur so users aren't scolded mid-keystroke.
Why
Separating field data (field(...)) from rendering (useField('name')) unlocks reuse: one set of field definitions can back multiple layouts (a mobile single-column, a desktop two-column) without duplicating the validators or the state keys. Context-based reads kill prop-drilling through deep nested forms.
The old API (everything in useForm({ initialValues, validators })) is still supported for quick inline forms but doesn't compose.
Anti-pattern
// BROKEN — reads the field's value ONCE at setup
function EmailInput() {
const email = useField('email').value() // a plain string snapshot
return <input value={email} /> // never updates
}
// FIX — keep the signal, call it inside the reactive scope.
// (Destructuring `const { value } = useField('email')` is fine too:
// `value` is the field's signal, so `value()` in JSX stays reactive.)
function EmailInput() {
const f = useField('email')
return <input value={f.value()} onInput={(e) => f.value.set(e.currentTarget.value)} />
}// BROKEN — validator reads props.X at setup, captures the initial value
const passwordField = field('password', '', (v) =>
v.length < props.minLength ? 'Too short' : undefined, // props.minLength is undefined
)Validators are pure functions called on every validation run. Read configuration through closure bindings that are final at field-definition time, or restructure to pull validators inside a component that has props in scope.
Related
Reference API:
useForm,useField,Form,Submit— seeget_api({ package: "form", symbol: "..." })Anti-pattern: "Destructuring props" in
reactivitycategory (same failure mode applies touseFielddestructuring)