Skip to content

React Guide

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