@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
Maintainers
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 thename/ref/onChange/onBlur/defaultValueprops 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
useFormcomponent. - One validation engine.
vin-validationdoes 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/reloadpopulate the form from a server function; server-sidevin-validationerrors 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
instanceIdto eachSelect.
Installation
npm install @kmacute/vin-use-form @kmacute/vin-validation react-selectPeer 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 withsetValue/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-reactwithreactCompilerPreset(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.isLoadingistrueduring load.- Loaded values populate registered fields (and late-mounting conditional fields) automatically.
- Loaded values become the new baseline —
formState.isDirtyisfalseuntil the user edits. form.formState.loadErrorholds a thrown load error; it is not mapped to field validation errors.onLoadmay be sync or async. It runs exactly once per mount (StrictMode safe).await form.reload()re-runsonLoadand 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:
preventDefault()and duplicate-submission guard (isSubmitting).- Automatic
clearErrors()— every submit attempt starts clean. - Collects current uncontrolled values (DOM-backed).
- Validates with
vin-validation(via.client()sounique/existsrules don't run against the DB in the browser). - On failure: maps every error to its field (dotted paths supported) and
does not call
onSubmit. - On success: calls
onSubmit(parsedValues). - Maps server-side errors, updates
submitError/isSubmitSuccessful, and always restoresisSubmitting.
Submission state:
form.formState.isSubmitting
form.formState.isSubmitted
form.formState.isSubmitSuccessful
form.formState.submitCount
form.formState.submitError // form-level, non-field errorValidation
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, resolvetruewhen 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.messageacross 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-formrecognizes{ ok: false, message?, errors? }and maps the field errors. A locally-thrownValidationError(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 baselinereset 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 destructuringuseWatch()
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/useFormContextare normal hooks called at the top of components — the compiler handles them. Only files callingregisterReactSelectneed'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 errorNested 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
vin-validationerrors (thrown or returned) → field errors, automatically.- Expected application errors (returned
{ ok: false, message }) →formState.submitError. - 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
