npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kmacute/vin-use-form

v0.3.0

Published

Lightweight, performant React Hook Form–inspired useForm() for TanStack Start, with @kmacute/vin-validation as the validation engine. Uncontrolled native fields, efficient subscriptions, automatic client + server validation error mapping, async onLoad/onS

Downloads

78

Readme

@kmacute/vin-use-form

A lightweight, performant, React Hook Form–inspired useForm() for React and TanStack Start, with @kmacute/vin-validation as the validation engine.

Design goals:

  • Native form fields stay uncontrolled. register() hands the DOM the name/ref/onChange/onBlur/defaultValue props and then gets out of the way — the DOM owns the live value.
  • Efficient subscriptions. A keystroke in one field never re-renders unrelated fields. Per-field listeners only fire for that field's state; form-level state changes (submit, load, error mapping, dirty flip) are the only ones that re-render the useForm component.
  • One validation engine. vin-validation does client-side validation (.client()), server-side validation, and every error mapping. The form never invents a second validation API.
  • TanStack Start first-class. Async onLoad/reload populate the form from a server function; server-side vin-validation errors are mapped to fields automatically.
  • React Select integration that preserves complete option objects (single, multi, null).

This is not a React Hook Form clone. It implements the small, focused API described below and deliberately omits RHF features that don't serve the architecture.

SSR (TanStack Start)

useForm is server-render safe: every subscription provides getServerSnapshot, so routes render fully server-side and hydrate cleanly. Two gotchas seen in practice:

  • React Compiler cannot see hook calls made inside form.registerReactSelect(...) — add 'use no memo' to any file that calls it (see the React Select section).
  • react-select generates ids from a module-level instance counter that diverges between the server and client bundles, producing hydration warnings. Pass a stable instanceId to each Select.

Installation

npm install @kmacute/vin-use-form @kmacute/vin-validation react-select

Peer dependencies: react ^18 || ^19, @kmacute/vin-validation ^0.3. react-select is an optional peer — only needed if you use registerReactSelect().


Basic usage

import { useForm } from '@kmacute/vin-use-form'
import { v } from '@kmacute/vin-validation'

const createUserSchema = v.object({
  name: v.string().required().min(3),
  email: v.email().required(),
  role: v.enum(['admin', 'member']).required(),
})

function CreateUser() {
  const form = useForm({
    defaultValues: { name: '', email: '', role: '' },
    validation: createUserSchema,
    onSubmit: async (values) => {
      await createUser({ data: values })
    },
  })

  return (
    <form.FormProvider>
      <input {...form.register('name')} />
      {form.errors.name?.[0]}

      <input type="email" {...form.register('email')} />
      {form.errors.email?.[0]}

      <select {...form.register('role')}>
        <option value="">Choose…</option>
        <option value="admin">Admin</option>
        <option value="member">Member</option>
      </select>

      <button type="submit">Save</button>
    </form.FormProvider>
  )
}

<form.FormProvider> renders the native <form> element and wires onSubmit to form.handleSubmit automatically — same result as the explicit form below:

<form onSubmit={form.handleSubmit}>…</form>

onSubmit receives the parsed/validated values from vin-validation (Infer<typeof createUserSchema>), never the raw unvalidated input. Invalid forms never call onSubmit.


Native inputs

<input {...form.register('name')} />
<textarea {...form.register('bio')} />
<select {...form.register('role')}>{/* options */}</select>
<input type="checkbox" {...form.register('newsletter')} />
<input type="radio" value="male" {...form.register('gender')} />
<input type="number" {...form.register('age', { valueAsNumber: true })} />

The returned props handle name, a stable ref, onChange, and onBlur. The ref initializes the element from the current form value at mount (no defaultValue/defaultChecked props — that avoids React's value+defaultValue warning on radios and keeps conditional remounts fresh). Extra handlers and coercion are supported via options:

form.register('age', {
  valueAsNumber: true,          // DOM string → number
  onChange: (e) => analytics.log(e.target.value),
  onBlur: (e) => console.log('blurred'),
})

Fields are uncontrolled: typing does not write to React state, and a change to one input does not re-render the rest of the form.


React Select

import Select from 'react-select'

const countries = [
  { value: 'ph', label: 'Philippines' },
  { value: 'us', label: 'United States' },
]

// Single select — the complete option object becomes the form value.
<Select {...form.registerReactSelect('country')} options={countries} />

// Multi select — the value is an array of complete option objects.
<Select isMulti {...form.registerReactSelect('countries')} options={countries} />

The form value for country is the full option ({ value: 'ph', label: 'Philippines' }), not a reduced 'ph'. Clearing a select stores null, never ''. setValue/reset/reload/onLoad update the select via the controlled value prop.

registerReactSelect() must be called unconditionally during render (like a hook). It subscribes the calling component to that field's value so the select stays in sync with setValue/reset/onLoad.

React Compiler note: the React Compiler cannot see hook calls made inside a plain method like form.registerReactSelect(...). If your app uses @vitejs/plugin-react with reactCompilerPreset (this repository does), add 'use no memo' at the top of any file that calls it — or wrap the call in a proper custom hook (const props = useMemo(() => form.registerReactSelect('country'), [form]) is not enough — the hooks must be called from a hook or component, so use a small wrapper hook).


Async loading

const form = useForm({
  defaultValues: { name: '', email: '' },
  onLoad: async () => {
    return await getUser()        // TanStack Start server function
  },
})

The library owns the onLoad → populate fields → clean baseline lifecycle:

  • form.formState.isLoading is true during load.
  • Loaded values populate registered fields (and late-mounting conditional fields) automatically.
  • Loaded values become the new baselineformState.isDirty is false until the user edits.
  • form.formState.loadError holds a thrown load error; it is not mapped to field validation errors.
  • onLoad may be sync or async. It runs exactly once per mount (StrictMode safe).
  • await form.reload() re-runs onLoad and replaces unsaved changes with the freshly loaded baseline.

Submission

const form = useForm({
  defaultValues,
  validation: createUserSchema,
  onSubmit: async (values) => {
    await createUser({ data: values })   // TanStack Start server function
  },
})

<form.FormProvider>

The provider renders the native <form> and binds onSubmit to form.handleSubmit. If you prefer an explicit element, the raw form is equivalent:

<form onSubmit={form.handleSubmit}>

handleSubmit runs the full pipeline:

  1. preventDefault() and duplicate-submission guard (isSubmitting).
  2. Automatic clearErrors() — every submit attempt starts clean.
  3. Collects current uncontrolled values (DOM-backed).
  4. Validates with vin-validation (via .client() so unique/exists rules don't run against the DB in the browser).
  5. On failure: maps every error to its field (dotted paths supported) and does not call onSubmit.
  6. On success: calls onSubmit(parsedValues).
  7. Maps server-side errors, updates submitError/isSubmitSuccessful, and always restores isSubmitting.

Submission state:

form.formState.isSubmitting
form.formState.isSubmitted
form.formState.isSubmitSuccessful
form.formState.submitCount
form.formState.submitError   // form-level, non-field error

Validation

vin-validation is the only validation engine — no second validator exists inside this library. The form validates with validation.client() so DB-backed unique/exists rules are stripped client-side, exactly like the repository's existing usage.

  • await form.validate() — validate everything, map all errors, resolve true when valid.
  • await form.trigger('email') / trigger(['email', 'name']) — validate and map errors only for the given paths.
  • form.setError('email', 'Invalid email') / setError(name, string[])
  • form.setErrors({ email: ['Already taken'], name: ['Required'] })
  • form.clearErrors() / clearErrors('email')

Validation modes

mode: 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched'   // default 'onSubmit'
reValidateMode: 'onChange' | 'onBlur' | 'onSubmit'      // default 'onSubmit'

mode controls when field-level validation first runs; reValidateMode controls when fields re-validate after the first submit.


Server errors

Server-side vin-validation errors are mapped to fields automatically — no try/catch + form.setError in application code:

onSubmit: async (values) => {
  const result = await createUser({ data: values })   // returns the envelope below
  return result
}

Three error categories are distinguished:

| Category | Detection | Handling | | --- | --- | --- | | Validation error | thrown ValidationError, or any thrown/returned value shaped { errors: Record<string, string[]> } | mapped to field errors automatically | | Expected application/server error | returned { ok: false, message? } | exposed via form.formState.submitError | | Unexpected error | anything else thrown | re-thrown — never silently swallowed |

TanStack Start note: thrown errors only serialize error.message across the wire (ShallowErrorPlugin), so field errors are lost. The repository convention — and the recommended pattern — is for server functions to return a failure envelope:

// server fn
const result = await schema.safeParseAsync(data)
if (!result.success) {
  return { ok: false, message: result.error.message, errors: result.error.errors }
}

vin-use-form recognizes { ok: false, message?, errors? } and maps the field errors. A locally-thrown ValidationError (client-side) is also caught and mapped.


Reset / reload

form.reset()                 // back to the initial defaultValues
form.reset({ name: 'John' }) // supplied values become the new baseline
await form.reload()          // re-run onLoad(); loaded values become the baseline

reset updates the baseline, writes registered native elements and controlled adapters, clears errors/touched/dirty, and resets submission state. After reset(values), isDirty is false until the user edits.


Values

form.getValue('user.name')
form.getValues()                     // full nested object
form.getValues(['email', 'name'])    // subset
form.setValue('user.name', 'John')
form.setValues({ name: 'John', email: '[email protected]' })

getValue/getValues read registered native elements directly (the DOM is the source of truth — autofill included), falling back to the internal mirror for unmounted/controlled fields. setValue writes the actual DOM element:

setValue('name', 'John')  →  input.value = 'John'

Nested dot notation (user.name, address.city, items.0.product) is supported end to end: registration, values, errors, validation.

setValue does not mark the form dirty by default (RHF semantics); pass { shouldDirty: true } to opt in. registerReactSelect's onChange opts in automatically.


Form context & watchers

FormProvider + useFormContext()

The form instance carries its own provider, which renders a native <form> element, so the whole subtree can access the same controller without prop drilling:

function UserForm() {
  const { form } = useForm({ defaultValues: { name: '', email: '' } })

  return (
    <form.FormProvider onSubmit={form.handleSubmit} className="space-y-5" noValidate>
      <UserFields />
    </form.FormProvider>
  )
}

<form.FormProvider> is equivalent to <form onSubmit={form.handleSubmit}>: onSubmit defaults to form.handleSubmit (any explicit onSubmit overrides it) and every other prop — className, noValidate, … — is forwarded to the underlying <form> element. children renders inside the form.

function UserFields() {
  const form = useFormContext<{ name: string; email: string }>()
  return (
    <>
      <input {...form.register('name')} />
      {form.errors.email?.[0] && <span>{form.errors.email[0]}</span>}
    </>
  )
}

useFormContext() returns the exact same instance as useForm() (contextForm === rootForm). It subscribes the consumer to form-level state, so reading form.errors / form.formState is reactive. Used outside a FormProvider it throws a clear error.

useForm returns the controller itself, so both styles work:

const form = useForm(...)       // form.register('name')
const { form } = useForm(...)   // same object, RHF-style destructuring

useWatch()

Subscribe a component to specific field values — observational only, never controls the input:

const name = useWatch('name')                  // single value
const values = useWatch(['name', 'email'])     // { name, email }
const values = useWatch()                      // entire form (re-renders on any value change)

Typing into <input {...form.register('name')} /> updates useWatch('name') consumers; the input stays uncontrolled. Value changes re-render only watchers of the changed field(s); error/touched/dirty changes never re-render value watchers. Watcher subscriptions are cleaned up on unmount and are StrictMode-safe.

Typing: since useWatch('name') has no argument to infer the values shape from, pass the form explicitly for full inference (the form's type flows into the return):

function NamePreview({ form }: { form: FormApi<MyValues, unknown> }) {
  const name = useWatch({ form, name: 'name' })  // name: string
  return <span>{name}</span>
}

Or provide both generics when using the provider form: useWatch<MyValues, 'name'>('name'). Without either, the return type is loose (unknown).

A defaultValue is honored only when the watched path has no value: useWatch('name', '').

React Compiler note: like registerReactSelect, useWatch/useFormContext are normal hooks called at the top of components — the compiler handles them. Only files calling registerReactSelect need 'use no memo'.


Field state

form.getFieldState('email')
// { error, isDirty, isTouched, isValidating }

For reactive per-field display, subscribe with useFieldState — the component only re-renders when that field's state changes:

function EmailField({ form }) {
  const { error, isDirty, isTouched } = useFieldState(form, 'email')
  return <div>{isDirty && 'dirty'} {isTouched && 'touched'} {error}</div>
}

To keep unrelated fields from re-rendering when the useForm component re-renders (submit/error/dirty flips), memoize field components (React.memo) — the form instance is stable, so memo works trivially.


Field arrays

Uncontrolled, keyed rows with stable element identity. Works standalone inside a FormProvider, or through the form instance:

const items = useFieldArray('items')      // requires <form.FormProvider>
// …or, without a provider:
const items = form.useFieldArray('items')

{items.fields.map((field, index) => (
  <div key={field.key}>
    <input {...form.register(`items.${index}.product`)} />
    <input {...form.register(`items.${index}.quantity`, { valueAsNumber: true })} />
    <button onClick={() => items.remove(index)}>Remove</button>
  </div>
))}
<button onClick={() => items.append({ product: '', quantity: 0 })}>Add</button>

fields are { id, key } pairs whose identity is stable across append/prepend/insert/remove/swap/move/update, so inputs are never re-created and focus is preserved. The element bound to a shifted path rebinds in place — typing after a remove(0) lands on the correct new path (items.0.x) on the very same input element.

items.append({ product: '', quantity: 0 })      // add at the end
items.prepend({ product: '', quantity: 0 })     // add at index 0
items.insert(1, { product: '', quantity: 0 })   // add at index 1
items.remove(0)                                 // remove index 0
items.swap(0, 1)                                // swap two rows
items.move(2, 0)                                // move a row
items.update(0, { product: 'x', quantity: 1 })  // replace in place (DOM + mirror)

onLoad/reset/reload regenerate the rows (fresh ids) so loaded arrays appear automatically. Structural operations clear validation errors beneath the array path (items.0.*), since shifted rows invalidate those paths.


Types

useForm infers the form value space from defaultValues:

const form = useForm({ defaultValues: { name: '', age: 0 } })
form.register('name')  // ✓ typed
form.register('age')   // ✓ typed
form.register('oops')  // ✗ TypeScript error

Nested paths are typed ('user.name', 'items.0.product'). When validation is provided, onSubmit values are typed as Infer<typeof validation> — the parsed/transformed output — with no manual type duplication.

If defaultValues is {} (empty), pass the shape explicitly:

const form = useForm<MyValues>({ defaultValues: {} })

How re-renders are kept minimal

field change (keystroke)
   ├─→ that field's listeners only        (useFieldState, registerReactSelect)
   └─→ form listeners ONLY if isDirty flipped (once per field)
submit / load / errors / touched
   └─→ form listeners (the useForm component)

Native inputs are uncontrolled, so typing causes zero React re-renders for fields that don't subscribe. The /vin-use-form test route ships a 60-field performance section with per-field render counters to verify this.


Error handling rules

  1. vin-validation errors (thrown or returned) → field errors, automatically.
  2. Expected application errors (returned { ok: false, message }) → formState.submitError.
  3. Unexpected runtime errors → re-thrown, never swallowed.

Global configuration

Configure the library once at application startup (e.g. in the router entry module of a TanStack Start app):

import { configureVinUseForm } from '@kmacute/vin-use-form'

configureVinUseForm({ trackDirty: true })

Options are read lazily, so the config applies to forms created afterwards and to already-mounted forms on their next operation. Calling it again merges overrides; getVinUseFormConfig() returns a snapshot.

| Option | Default | Description | | --- | --- | --- | | trackDirty | false | Track per-field/form dirty state. When off, dirty bookkeeping is skipped entirely: isDirty stays false, per-keystroke value/baseline comparisons are skipped, and setValue(name, value, { shouldDirty: true }) is a no-op. Enable it when your UI displays dirty state. | | trackTouch | false | Track per-field touched state. When off, native blur does not mark fields touched: getFieldState(name).isTouched stays false and formState.touchedFields stays empty. The 'onTouched' validation mode requires this to be enabled. |


License

MIT