Skip to content

Community

The Neutro Ecosystem

Neutro is a collection of focused, zero-dependency primitives for the web. Each package does one thing well.

PackageDescriptionStatus
@neutro/formReactive form engine for every frameworkv0.5.0 — stable
@neutro/fluidPhysics-grounded glass material system for the webIn development

Filing Issues & Feature Requests

When contributing code, use Conventional Commits format — releases are automated based on commit prefixes.

All issues and requests are tracked on GitHub.

  • Bug report — something isn't working as documented → Open an issue
  • Feature request — something you'd like to see → Open an issue with the enhancement label
  • Question — not sure if it's a bug → Start a discussion

When filing a bug, please include the package version, a minimal reproduction, and what you expected vs. what happened.


Support the Project

If @neutro/form saves you time, consider buying me a coffee. It helps keep the packages maintained and the documentation up to date.

Buy Me A Coffee

FAQ

Core Concepts

Can I use the core engine without a framework adapter?

Yes. @neutro/form/core has zero runtime dependencies and works in any JavaScript environment — plain HTML, web components, Node.js, or alongside any UI library without an official adapter.

ts
import { createForm } from '@neutro/form/core'

const form = createForm({ initialValues: { email: '' } })
form.subscribe(state => console.log(state.values))

Why doesn't it use a global store or context provider?

createForm is a closure factory. All state lives inside the closure, not in a module-level singleton. This means multiple forms on the same page never share state accidentally, SSR is safe (no state to leak between requests), and tree-shaking works naturally. You don't need to wrap your app in a Provider.

Can multiple forms share state?

No — and that's intentional. Each createForm() call produces a fully isolated instance. If you need two forms to coordinate, read values from one and pass them to the other explicitly.

Which import should I use — @neutro/form/core or @neutro/form/core/minimal?

Use this decision tree:

  • Does your form use dynamic arrays (arrayAppend/arrayRemove/arrayMove/arraySwap)? → @neutro/form/core
  • Does it call connect(), focus(), or focusFirstError() (vanilla-DOM binding)? → @neutro/form/core
  • Does it auto-save/restore via persistence/hydrate()? → @neutro/form/core
  • Does it derive a field's value from other fields via computed? → @neutro/form/core
  • None of the above? → @neutro/form/core/minimal (~8.0 KB gzip vs ~11.8 KB gzip for the full tier)

minimal is a strict subset — the createForm it exports covers set/get/validate/subscribe/reset/submit/batch and friends, but silently no-ops on computed and persistence config (exactly like passing them without the feature would). See the Bundle Size Tiers guide for the full breakdown and the one-line upgrade path.


Validation

When do validation errors show up?

Controlled by validationMode. The default, 'onTouched', is silent until the user leaves a field for the first time, then switches to live keystroke feedback. See the Validation Modes guide for the full breakdown.

Is there a separate "re-validate mode" after submission?

No — and that's deliberate. After a failed submit, each field continues to follow its configured validationMode. There's no second reValidateMode option to keep track of. onTouched fields keep giving live feedback; onSubmitOnly fields stay quiet until the next submit.

How do I validate one field based on another field's value?

Use the dependencies config. Declare which fields trigger revalidation of other fields — the engine builds a transitive closure at init time, so no manual trigger() calls are ever needed.

ts
const form = createForm({
  initialValues: { startDate: '', endDate: '' },
  dependencies: { endDate: ['startDate'] },
  validator({ startDate, endDate }) {
    const errors: Record<string, string> = {}
    if (endDate && startDate && endDate <= startDate)
      errors.endDate = 'Must be after start date'
    return errors
  },
})

Changing startDate automatically re-validates endDate. No wiring required.

How do I place a cross-field error on a specific field?

Return a Record<string, string> from your validator where the key is the field path. The error lands exactly where you put it — no .superRefine() or path configuration.

ts
validator({ password, confirmPassword }) {
  if (password !== confirmPassword)
    return { confirmPassword: 'Passwords do not match' }
  return {}
}

Can I use Zod, Yup, or Valibot?

Yes — @neutro/form/core ships built-in adapters for all three. Pass the adapter as your validator:

ts
import { createForm, zodAdapter } from '@neutro/form/core'
import { z } from 'zod'

const form = createForm({
  initialValues: { email: '', age: 0 },
  validator: zodAdapter(
    z.object({
      email: z.string().email('Must be a valid email'),
      age: z.number().min(18, 'Must be 18 or older'),
    })
  ),
})

Swap zodAdapter for valibotAdapter or yupAdapter for those libraries — the API is identical. See the Validation Adapters reference for full details.

For any schema library without a built-in adapter, wrap its parse result manually:

ts
validator(values) {
  const result = mySchema.safeParse(values)
  if (result.success) return {}
  return Object.fromEntries(
    result.error.issues.map(i => [i.path.join('.'), i.message])
  )
}

Can I use class-validator?

Yes — classValidatorAdapter is a built-in adapter, useful in Angular projects that share DTO classes with a NestJS backend.

ts
import { classValidatorAdapter } from '@neutro/form/core'
import { validate, IsEmail, MinLength } from 'class-validator'

class SignUpDto {
  @IsEmail({}, { message: 'Must be a valid email' })
  email!: string

  @MinLength(8, { message: 'Min 8 characters' })
  password!: string
}

const form = createForm({
  initialValues: { email: '', password: '' },
  validator: classValidatorAdapter(SignUpDto, validate),
})

Nested DTOs are fully supported — ValidationError.children is traversed recursively and errors are flattened to dot-notation paths automatically (address.city, order.billing.postalCode).

See the Validation Adapters reference for full details.

How do I handle async validation (e.g., check if a username is taken)?

Use an async validator. Each invocation gets its own AbortController — stale in-flight requests are cancelled automatically when the field changes again.

ts
const form = createForm({
  initialValues: { username: '' },
  async validator({ username }, _scope, signal) {
    if (!username) return { username: 'Required' }
    const taken = await checkUsername(username, { signal })
    return taken ? { username: 'Already taken' } : {}
  },
  asyncDebounceMs: 400, // debounce before firing the request
})

How do I debounce validation to avoid firing on every keystroke?

Set asyncDebounceMs in the form config. The default is 300ms. Set it to 0 in tests so async validators resolve without fake timers.

How do I validate only specific fields?

ts
await form.validate(['email', 'username']) // validate a subset
await form.validate()                       // validate everything

How do I conditionally require a field?

Use requiredIf or requiredUnless in the built-in rules, or express the condition in your validator function:

ts
validator({ role, teamId }) {
  if (role === 'member' && !teamId)
    return { teamId: 'Required for team members' }
  return {}
}

How do I disable inline validation for a specific field?

Set validationMode per-field using the fields map:

ts
validationMode: {
  default: 'onTouched',
  fields: { terms: 'onSubmitOnly' } // no inline validation for the checkbox
}

Resetting & Loading Data

How do I populate the form with data fetched from the server?

Call form.reset(fetchedValues) after your data arrives. It atomically re-seeds all state — values, errors, touched, dirty — in one batch.

ts
const user = await api.getUser(id)
form.reset(user)

No timing issue: reset() is synchronous. Calling form.validate() immediately after validates the new values, not the old ones.

How do I reset a form after a successful submit?

ts
await form.submit(async (values) => {
  await api.save(values)
  form.reset() // back to initialValues
  // or: form.reset(newSeed) to reset to different values
})

I called reset() but the form still shows old errors on the next submit.

reset() clears all state including errors and touched. If errors reappear immediately after reset, it means validation ran again (perhaps you called validate() or the validation mode re-triggered on mount). Check that you're not calling validate() right after reset() when you don't intend to.

How do I auto-save form values to localStorage?

Use localStorageAdapter and call form.hydrate() once after mount:

ts
import { createForm, localStorageAdapter } from '@neutro/form/core'

const form = createForm({
  initialValues: { email: '' },
  persistence: { adapter: localStorageAdapter('my-form') },
})

await form.hydrate() // call this once after mount

Values are written on every change (debounced to 300ms by default). form.reset() clears the storage slot. See the Persistence guide for full details.

How do I reset a single field to its initial value?

ts
form.resetField('email')

This restores the field's value to what it was in initialValues (or the seed from the last reset(newValues) call). It clears errors, touched, and dirty for that path. Use options to preserve any of those:

ts
form.resetField('email', { keepError: true })

For nested objects, resetField('address') clears all address.* state. For array items, resetField('items.0.name') clears only that leaf.


Submission

How do I know if the form is valid without calling validate()?

Read state.isValid. It has three possible values:

  • null — the form has not been fully validated yet (initial state or after reset()).
  • true — the last full validate() or submit() found no errors.
  • false — the last full validation found errors, or setErrors() injected errors.
ts
// Conservative: disable submit until known-valid
<button disabled={state.isValid !== true}>Submit</button>

// Optimistic: only disable if known-invalid  
<button disabled={state.isValid === false}>Submit</button>

Scoped form.validate(['email']) does not change isValid — only a full form.validate() or form.submit() does.

How do I prevent double submission?

state.isSubmitting is set to true for the duration of the submit handler. Bind it to your button's disabled prop.

ts
form.subscribe(({ isSubmitting }) => {
  submitButton.disabled = isSubmitting
})

What happens if my submit handler throws?

form.submit() catches the throw, sets isSubmitting back to false, and re-throws so you can handle it in the call site. The form remains in its last validated state.

How do I inject server-returned field errors after a failed API call?

form.setErrors() merges server errors into form state. They behave identically to client errors and clear on the next validation run.

ts
await form.submit(async (values) => {
  const res = await api.register(values)
  if (!res.ok) form.setErrors(res.errors) // { email: 'Already taken' }
})

How do I submit only the fields that changed (dirty fields)?

ts
await form.submit(async (values) => {
  const dirty = form.getState().dirty     // Record<string, boolean>
  const patch = Object.fromEntries(
    Object.entries(values).filter(([k]) => dirty[k])
  )
  await api.patch(patch)
})

How do I know if a specific field has been validated?

Use form.isFieldValid(path) — returns null before first validation, true if the field is valid, false if errors exist.

ts
const valid = form.isFieldValid('email')
// null  → never validated
// true  → validated, no error
// false → validated, has error

How do I focus the first error after submission?

Call form.focusFirstError() — it focuses the first error element in DOM document order. Requires fields to be connected via form.connect(). Returns false if no errors or no connected elements.

ts
const ok = await form.submit(onValid)
if (!ok) form.focusFirstError()

How do I run logic after a successful or failed submission?

Use onSubmitSuccess and onSubmitError in createForm config:

ts
const form = createForm({
  initialValues: { email: '' },
  onSubmitSuccess: (payload) => {
    toast.success('Saved!')
    analytics.track('form_submit', payload)
  },
  onSubmitError: (error, payload) => {
    console.error('Submit failed', error)
  },
})

How do I access the values from the last successful submission?

Read state.lastSubmittedValues. It contains a deep snapshot of the form values at the moment the most recent successful submit completed, or null if no successful submission has occurred yet.

ts
const state = form.getState()
if (state.lastSubmittedValues) {
  console.log('last submitted email:', state.lastSubmittedValues.email)
}

This is useful for showing a "you changed X since your last save" diff or for conditional submit-button labelling.

How do I know how many times the form has been submitted?

Read state.submissionAttempts. It increments on every submit() call, including failed ones. Use it to show "Submit (attempt 3)" or to gate retry logic.

ts
form.subscribe(({ submissionAttempts }) => {
  if (submissionAttempts > 3) showExtraHelp()
})

Dynamic Forms & Arrays

How do I build a path at runtime when TypeScript can't infer it?

Use setDynamic and getDynamic. These bypass the compile-time Path<T> constraint — useful when the path is computed from a variable, loop index, or user input.

ts
const index = selectedRow
const namePath = `items.${index}.name`

form.setDynamic(namePath, 'Updated name')
const current = form.getDynamic(namePath) // returns unknown

For subscriptions to runtime paths, use subscribeToPathDynamic:

ts
const unsub = form.subscribeToPathDynamic(namePath, (value) => {
  console.log('field changed:', value)
})
// later:
unsub()

How do I add and remove fields at runtime?

Use the built-in array operations. They keep errors, touched, and dirty in sync with the new indices automatically — no manual index management.

ts
import { arrayAppend, arrayRemove } from '@neutro/form/core'

arrayAppend(form, 'items', { name: '', qty: 1 })
arrayRemove(form, 'items', 2) // remove index 2; indices 3+ shift down

Does removing an item from the middle of an array shift error state correctly?

Yes. arrayRemove calls shiftStateIndices internally, renumbering all errors.items.*, touched.items.*, and dirty.items.* keys so that what was index 3 becomes index 2, and so on.

How do I handle nested arrays (e.g., invoice items, each with multiple tax lines)?

Use dot-notation paths. Array operations accept any depth:

ts
arrayAppend(form, 'items.0.taxes', { rate: 0, label: '' })
arrayRemove(form, 'items.0.taxes', 1)

Validation paths follow the same notation: 'items.0.taxes.1.rate'.

Raw arrays nested inside arrays (no object wrapper, e.g. cube: number[][][]) work identically at runtime, but Path<T> only type-checks one level of raw nesting — 'cube.0.1' compiles, 'cube.0.1.2' needs a cast (as Path<T>). See TypeScript Guide → Strict path types for the full explanation.

How do I handle conditional fields that appear and disappear?

If you're using the DOM bridge (connect()), the MutationObserver automatically removes a field's state when its element is disconnected from the DOM. Use persistedPaths to keep a field's value alive even when unmounted — useful for multi-step wizards.

For framework-adapter patterns without the DOM bridge, call form.set(path, undefined) when hiding a field to clear its value.

How do I derive a field value from other fields automatically?

Use the computed config. Computed fields are read-only — calling set() on them is a no-op. The engine re-evaluates them whenever any of their dependencies change.

ts
const form = createForm({
  initialValues: { price: 10, qty: 3, total: 0 },
  computed: {
    total: { fn: (values) => (values as any).price * (values as any).qty },
  },
})

form.set('qty', 5)
form.getState().values.total // 50

Computed fields participate in batch updates — all computed re-evaluations happen before subscribers are notified.

How do I watch one or more field values without subscribing to the whole form?

Use form.watch() for the core engine, or the framework-specific hook (useWatch, useVueWatch, etc.) for component-level reactivity.

ts
// core
const stop = form.watch(['email', 'username'], (snapshot) => {
  console.log(snapshot.email, snapshot.username)
})
stop() // unsubscribe

// React
const { email, username } = useWatch(form, ['email', 'username'])

Each path fires its callback only when that path's value actually changes. The snapshot is deep-cloned — mutating it does not affect internal form state.


Integration with UI Libraries

How do I use it with shadcn/ui, MUI, or other component libraries?

Call form.set() in the component's change handler and read form.get() (or subscribe) for the value. No wrapper component or Controller pattern is needed — the engine is fully decoupled from the DOM.

tsx
// shadcn Select
<Select
  value={form.get('country')}
  onValueChange={(value) => form.set('country', value, { touch: true })}
/>

How do I handle <input type="number">?

Convert the string to a number in your set() call. Since you explicitly call set(), there's no hidden coercion — you control the type.

ts
input.addEventListener('input', (e) => {
  form.set('age', Number((e.target as HTMLInputElement).value), { touch: true })
})

state.values.age will be a number, not a string.

How do I handle file inputs?

form.set('avatar', fileInput.files[0]) stores the File object in form state. Validate it in your validator function:

ts
validator({ avatar }) {
  if (!avatar) return { avatar: 'Required' }
  if (avatar.size > 5 * 1024 * 1024) return { avatar: 'Max 5 MB' }
  return {}
}

After form.reset(), clear the file input element separately — browsers do not allow programmatic filename clearing:

ts
form.reset()
fileInputRef.current.value = ''

How do I validate file inputs?

Use the built-in file rules:

ts
rules: {
  avatar: [
    'required',
    { maxFileSize: 5 * 1024 * 1024, message: 'Max 5 MB' },
    { fileTypes: ['image/jpeg', 'image/png', 'image/webp'] },
  ],
  attachments: [
    { minFiles: 1, message: 'Attach at least one file' },
    { maxFiles: 3 },
  ],
}

For FileList inputs (the value of input.files), use minFiles: 1 rather than 'required' — it is clearer about intent. A bare File value is counted as 1 by maxFiles and minFiles.


Performance

Will using useForm() in my component re-render it on every keystroke?

If you subscribe to the whole form state (useForm(), form.subscribe()), yes — any state change triggers a re-render. For field-level rendering, use useFormPath() (or form.subscribeToPath()) instead, which only fires when that specific path's state changes.

General pattern: use useForm() for form-level concerns (submit button disabled state, error summary) and useFormPath() per field for everything else.

How do I observe field values without re-rendering my whole component?

Use the framework-specific watch hook — useWatch (React), useVueWatch (Vue), useSvelteWatch (Svelte), useAngularWatch (Angular), or useSolidWatch (SolidJS). Each subscribes to one or more field values and updates only when those paths change, leaving unrelated state changes unnoticed.

tsx
// React example
const { email, username } = useWatch(form, ['email', 'username'])

See each framework guide for details.

Are array operations (append, remove, move) efficient?

Array operations run inside batch(), so all index-shifting happens before subscribers are notified. Subscribers receive a single notification regardless of how many keys were renumbered. This avoids the cascade of intermediate re-renders that uncoordinated mutations cause.


TypeScript

How do I make the form fully typed?

Pass your values interface as the generic to createForm<T>:

ts
interface SignupValues {
  email: string
  username: string
  age: number
}

const form = createForm<SignupValues>({
  initialValues: { email: '', username: '', age: 0 },
})

The returned FormInstance<T> carries all type information throughout its lifetime. You do not need to re-specify the generic at every call site.

Does TypeScript catch typos in field paths?

Yes, as of v0.4+. All path-accepting methods (set, get, arrayAppend, etc.) require Path<T> — a compile-time-checked union of every valid dot-notation path for your form's value type.

What works:

  • form.get('email') returns string — the returned value is fully typed.
  • form.set('email', 42) is a TypeScript error — number is not assignable to string.
  • form.set('emal', value) is a TypeScript error — 'emal' is not a valid path.
  • form.arrayAppend('items', 'wrong') is a TypeScript error — string is not assignable to the element type.
  • IDE autocomplete surfaces available paths and expected value types.

What doesn't work:

  • Raw array-of-array nesting past one level: for cube: number[][][], 'cube.0.1.2' needs an as Path<T> cast even though it's runtime-correct — see TypeScript Guide → Strict path types.
  • Dynamic paths: a path built at runtime (const p: string = ...) has no compile-time-known value, so it can't be checked against Path<T>.

How do I use a runtime-constructed path if Path<T> requires a compile-time-known string?

Use setDynamic(path, value) / getDynamic(path) — these accept a plain string and intentionally bypass Path<T> checking, for paths that genuinely aren't known until runtime (e.g. items.${index}.name). For other methods, cast with as Path<T> when you're confident the path is valid:

ts
const field = condition ? 'email' : 'username' // type: string
form.setDynamic(field, value)                   // ✅ bypasses Path<T> by design
form.isFieldDirty(field as Path<typeof form>)   // ✅ cast for methods without a Dynamic variant

Testing

How do I test form logic without rendering a component?

Use @neutro/form/testing. createFormFixture() wraps a real form instance with test helpers and sets asyncDebounceMs: 0 so async validators resolve without fake timers.

ts
import { createFormFixture } from '@neutro/form/testing'

const fx = createFormFixture({
  initialValues: { email: '' },
  rules: { email: ['required', 'email'] },
})

fx.fill({ email: 'bad' })
await fx.validate()
expect(fx.form.getState().errors.email).toBe('Invalid email')
fx.cleanup()

The package also exports fillForm, blurField, and triggerValidation as standalone functions for use against a form instance you created yourself. See API Reference → Testing Utilities for the full reference.

Do I need to mock the library in tests?

No. The testing package works with real form instances — no mocking, no React rendering, no Provider. createFormFixture gives you full control over a form's lifecycle in a few lines.

My framework-adapter tests show act() warnings.

The core engine is synchronous — no act() required for core logic. For framework-adapter tests (React components that render form state), wrap interactions in await userEvent.* and assertions in waitFor() as you would with any async state update.


SSR & React Native

Does it work with Next.js / Nuxt / SvelteKit (SSR)?

Yes. createForm is a closure with no module-level state — there is nothing to leak between server requests. Each call produces a fully isolated instance. The engine itself is SSR-safe; instantiate it in your component's setup code as you normally would.

Does it work with React Native?

The core engine and React adapter work in React Native — use form.set('field', value) in TextInput's onChangeText and form.get('field') for the value prop.

The DOM bridge (connect()) requires an HTMLElement and does not apply in React Native. There is no official React Native adapter today.


Honest Gaps

These are things @neutro/form does not do cleanly yet. They are not workarounds — if your project needs them, know this going in.

Strongly typed field paths — raw array-of-array nesting only partially caught — as of v0.4+, Path<T> catches ordinary path typos at compile time (form.set('emal', value) is now a TS error, not a silent pass). The remaining gap is narrower: a raw array nested directly inside another array (no object wrapper), e.g. cube: number[][][], only type-checks one level deep — 'cube.0.1' compiles, 'cube.0.1.2' needs an as Path<T> cast even though it's runtime-correct. Object-wrapped array nesting (items.0.taxes.1.rate) is unaffected. See TypeScript Guide → Strict path types. Deliberately-dynamic paths (setDynamic/getDynamic) still bypass compile-time checking by design — that's the documented escape hatch for runtime-constructed paths, not a gap.

React Native adapter — the core works but there is no official adapter with RN-idiomatic patterns.