React Guide
npm install @neutro/form
# pnpm add @neutro/form
# yarn add @neutro/formHook Overview
| Hook | Re-renders on | Best for |
|---|---|---|
useForm | Every state change | Submit button, form-level status |
useFormPath | Changes to one path | Individual controlled fields |
useFormConnect | Never | Uncontrolled / high-frequency inputs |
useForm — Global State
useForm creates (or receives) a form instance and subscribes to the full FormState<T>. The component re-renders whenever any field changes.
import { createForm } from '@neutro/form/core'
import { useForm } from '@neutro/form/adapters/react'
type LoginValues = {
email: string
password: string
}
const loginForm = createForm<LoginValues>({
initialValues: { email: '', password: '' },
validator: (values) => {
const errors: Record<string, string> = {}
if (!values.email.includes('@')) errors.email = 'Invalid email'
if (values.password.length < 8) errors.password = 'Min 8 characters'
return errors
},
})
export function LoginForm() {
const { values, errors, isSubmitting } = useForm(loginForm)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await loginForm.validate()
if (Object.keys(errors).length > 0) return
// submit...
}
return (
<form onSubmit={handleSubmit}>
<input
value={values.email}
onChange={(e) => loginForm.set('email', e.target.value, { touch: true })}
/>
{errors.email && <span>{errors.email}</span>}
<input
type="password"
value={values.password}
onChange={(e) => loginForm.set('password', e.target.value, { touch: true })}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
)
}useFormPath — Controlled Field
useFormPath subscribes to a single field path and returns the value at that path with its TypeScript type inferred automatically. The component re-renders only when that field's value changes — not when unrelated fields update.
import { useForm, useFormPath } from '@neutro/form/adapters/react'
function EmailField({ form }: { form: typeof loginForm }) {
const email = useFormPath(form, 'email') // inferred as string
const { errors, touched } = useForm(form) // for field metadata
return (
<div>
<input
value={email}
onChange={(e) =>
form.set('email', e.target.value, { touch: true, validate: true })
}
/>
{touched.email && errors.email && <span className="error">{errors.email}</span>}
</div>
)
}useFormPath returns the field value directly — not an object. Access errors and touched from useForm (or from form.getState() if you need them without subscribing).
useFormConnect — Uncontrolled (Zero Re-render)
useFormConnect takes the form instance and returns a curried ref-callback factory. Call it with a path (and optional ConnectOptions) to get a React ref callback — attach that to any DOM element for zero-rerender integration with the form. Ideal for high-frequency inputs like masked phone fields.
import { useFormConnect } from '@neutro/form/adapters/react'
function PhoneField({ form }: { form: typeof myForm }) {
const register = useFormConnect(form)
return (
<input
ref={register('phone', {
format: (v) => {
const digits = String(v).replace(/\D/g, '')
if (digits.length <= 3) return digits
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`
},
})}
type="tel"
placeholder="(555) 000-0000"
/>
)
}The element is disconnected automatically when the component unmounts. You can call register with multiple paths to wire up several inputs from the same hook.
Full TypeScript Example
import { createForm } from '@neutro/form/core'
import { useForm, useFormPath } from '@neutro/form/adapters/react'
import { zodAdapter } from '@neutro/form/core'
import { z } from 'zod'
const schema = z.object({
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
email: z.string().email('Invalid email'),
})
type ProfileValues = z.infer<typeof schema>
const profileForm = createForm<ProfileValues>({
initialValues: { firstName: '', lastName: '', email: '' },
validator: zodAdapter(schema),
asyncDebounceMs: 250,
})
function Field({ form, path, label }: {
form: typeof profileForm
path: keyof ProfileValues
label: string
}) {
const value = useFormPath(form, path) // inferred string
const { errors, touched } = useForm(form) // re-renders only matter at this level
return (
<label>
{label}
<input
value={value}
onChange={(e) => form.set(path, e.target.value, { touch: true, validate: true })}
/>
{touched[path] && errors[path] && <p className="error">{errors[path]}</p>}
</label>
)
}
export function ProfileForm() {
const { isSubmitting, errors } = useForm(profileForm)
const hasErrors = Object.keys(errors).length > 0
return (
<form onSubmit={async (e) => {
e.preventDefault()
await profileForm.validate()
}}>
<Field form={profileForm} path="firstName" label="First name" />
<Field form={profileForm} path="lastName" label="Last name" />
<Field form={profileForm} path="email" label="Email" />
<button type="submit" disabled={isSubmitting || hasErrors}>Save</button>
</form>
)
}When to Use Each Hook
useFormwhen the component needs to render based on aggregate form state (isSubmitting, whether any errors exist,valuesfor a preview, etc.).useFormPathfor individual field components that display their own value and error. Keeps re-renders scoped to the field.useFormConnectfor high-frequency inputs (phone masking, rich text, canvas drawing) where React re-renders on every keystroke would be too expensive, or for DOM elements you want to control imperatively.
Resetting a Single Field
resetField is available directly from the form instance and works the same in React. A common pattern is to reset a field when the user dismisses a modal or a section:
function ProfileForm() {
const { values, errors } = useForm(form)
return (
<div>
<input
value={values.email}
onChange={(e) => form.set('email', e.target.value, { touch: true })}
/>
{errors.email && (
<button onClick={() => form.resetField('email')}>
Reset email
</button>
)}
</div>
)
}resetField does not trigger React re-renders on its own — subscribers (via useForm or useFormPath) pick up the change through the normal subscription mechanism.
Handling Server Errors
Use form.setErrors() inside your submit handler to feed API validation errors back into form state. They surface in errors and clear on the next validation run — no extra wiring required.
import { createForm } from '@neutro/form/core'
import { useForm } from '@neutro/form/adapters/react'
const registerForm = createForm({
initialValues: { email: '', username: '' },
rules: { email: ['required', 'email'], username: 'required' },
})
export function RegisterForm() {
const { errors, touched, isSubmitting } = useForm(registerForm)
const handleSubmit = registerForm.handleSubmit(async (payload) => {
const res = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(payload),
})
if (!res.ok) {
const { errors } = await res.json()
registerForm.setErrors(errors)
}
})
return (
<form onSubmit={handleSubmit}>
<input
value={registerForm.get('email') as string}
onChange={(e) => registerForm.set('email', e.target.value, { touch: true })}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
<input
value={registerForm.get('username') as string}
onChange={(e) => registerForm.set('username', e.target.value, { touch: true })}
/>
{touched.username && errors.username && <span>{errors.username}</span>}
<button type="submit" disabled={isSubmitting}>Register</button>
</form>
)
}useWatch — Observe Field Values
useWatch(form, paths) subscribes to one or more field values and re-renders the component only when those paths change.
import { useWatch } from '@neutro/form/adapters/react'
function SummaryBar({ form }) {
const { email, username } = useWatch(form, ['email', 'username'])
return <p>{email} — {username}</p>
}Unlike useForm, useWatch does not re-render when unrelated fields change. This is its primary use case: reading a subset of form values in a component that should not re-render on full-form state updates.
Validation Modes
Configure when validation triggers globally and per field via validationMode in createForm:
const form = createForm({
initialValues: { email: '', password: '' },
validationMode: {
default: 'onTouched',
fields: { password: 'onChange' },
},
})For useFormConnect-wired inputs this is automatic. Pass validateOn to override for one element:
const register = useFormConnect(form)
// ...
<input ref={register('email', { validateOn: 'onBlur' })} />For controlled inputs, call form.getFieldMode(path) to implement the right event wiring:
function Field({ name }: { name: string }) {
const { get, set, validate } = useForm(form)
const mode = form.getFieldMode(name)
return (
<input
value={String(get(name) ?? '')}
onChange={e => {
set(name, e.target.value)
if (mode === 'onChange') validate([name])
}}
onBlur={() => {
if (mode === 'onBlur' || mode === 'onTouched') validate([name])
}}
/>
)
}