Skip to content

React Guide ​

v0.4 note: Adapter hooks still accept string for path parameters. Strict adapter path types arrive in v0.5.

sh
npm install @neutro/form
# pnpm add @neutro/form
# yarn add @neutro/form

Hook Overview ​

HookRe-renders onBest for
useFormEvery state changeSubmit button, form-level status
useFormPathChanges to one pathIndividual controlled fields
useFormConnectNeverUncontrolled / 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.

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

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

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

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

  • useForm when the component needs to render based on aggregate form state (isSubmitting, whether any errors exist, values for a preview, etc.).
  • useFormPath for individual field components that display their own value and error. Keeps re-renders scoped to the field.
  • useFormConnect for 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:

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

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

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

ts
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:

tsx
const register = useFormConnect(form)
// ...
<input ref={register('email', { validateOn: 'onBlur' })} />

For controlled inputs, call form.getFieldMode(path) to implement the right event wiring:

tsx
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])
      }}
    />
  )
}