Tiny headless forms for Nano Stores, with field state, synchronous or asynchronous validation, and submission handling in one store. It works with Nano Stores bindings for React, Preact, Vue, Svelte, Solid, or vanilla JavaScript, while optional helpers keep schemas, nested paths, arrays, debouncing, selection, and debugging out of the core entrypoint.
- Small. The core measures
1.08 kBminified and brotlied with dependencies. - Nano Stores native. A form is a store with
get(),listen(),subscribe(), and form actions. - Headless. Connect the store to any UI framework or use it directly in vanilla JavaScript.
- Modular. Optional helpers are available from separate entrypoints.
- Node.js. Requires Node.js 20 or later.
import { form } from '@alexcarpenter/form'
const $login = form({ email: '' }, {
validate: ({ values }) => values.email ? undefined : { email: 'Required' }
})
await $login.handleSubmit() //=> falseRequires Node.js 20 or later.
pnpm add nanostores @alexcarpenter/formUse form(defaultValues, options) for the smallest configuration:
import { form } from '@alexcarpenter/form'
const $login = form({ email: '', password: '' }, {
validate: ({ values }) => ({
email: values.email.includes('@') ? undefined : 'Invalid email',
password: values.password ? undefined : 'Required'
}),
onSubmit: async ({ values }) => {
await api.login(values)
}
})
$login.setValue('email', '[email protected]', false)
await $login.handleSubmit() //=> false until password is setIf you prefer the TanStack Form shape, use createForm() with defaultValues and validators:
import { createForm } from '@alexcarpenter/form'
const $login = createForm({
defaultValues: { email: '', password: '' },
validators: {
onChange: ({ value }) => value?.includes('@') ? undefined : 'Invalid email',
onSubmit: ({ values }) => ({
password: values.password ? undefined : 'Required'
})
},
onSubmit: async ({ values }) => api.login(values)
})createForm() and form() are aliases. Both configuration styles are supported.
Read the complete state from the Nano Stores store:
import { form } from '@alexcarpenter/form'
const $form = form({ email: '' })
$form.get()
//=> {
//=> values: { email: '' }, errors: {}, touched: {}, dirty: {},
//=> isSubmitting: false, isValidating: false,
//=> submitCount: 0, canSubmit: true
//=> }A form validator receives the current values and returns an object of field errors. A field validator receives field and value and returns that field's error directly.
import { form } from '@alexcarpenter/form'
const $form = form({ email: '' }, {
validate: ({ values }) => ({
email: values.email ? undefined : 'Required'
})
})
await $form.validate() //=> falseFor field-level validation:
import { form } from '@alexcarpenter/form'
const $form = form({ email: '' }, {
validate: ({ field, value }) => {
if (field === 'email' && !value) return 'Required'
}
})
await $form.validate('email', 'blur') //=> falseValidators can be async. Late async results are ignored if a newer validation started first. TanStack-style onChangeAsync, onBlurAsync, and onSubmitAsync validators are also supported, with onChangeAsyncDebounceMs, onBlurAsyncDebounceMs, onSubmitAsyncDebounceMs, or shared asyncDebounceMs options.
import { form } from '@alexcarpenter/form'
const $form = form({ username: '' }, {
validate: async ({ field, value }) => {
if (field === 'username' && !(await api.isUsernameAvailable(value))) {
return 'Username is taken'
}
}
})Fields are created lazily and are backed by the form store:
const $form = form({ email: '' })
const email = $form.field('email')
email.set('[email protected]')
await email.blur()
email.get()
//=> { name: 'email', value: 'alex@example.com', errors: [],
//=> touched: true, dirty: true }handleSubmit(event?) prevents the event default when provided, touches all fields, validates the form, and calls onSubmit only when valid. It resolves to true after submission or false when validation fails.
import { form } from '@alexcarpenter/form'
const $form = form({ email: '[email protected]' }, {
onSubmit: async ({ values }) => fetch('/login', {
body: JSON.stringify(values),
method: 'POST'
})
})
const ok = await $form.handleSubmit() //=> true
$form.resetField('email')
await $form.validateField('email')Optional features live in separate entrypoints so they are not included by core imports.
Use a validator that implements Standard Schema, such as Zod or Valibot:
import { form } from '@alexcarpenter/form'
import { standardSchema } from '@alexcarpenter/form/standard-schema'
const $form = form({ email: '' }, {
validate: standardSchema(schema)
})Delay writes to a field and control the pending write with cancel() or flush():
import { form } from '@alexcarpenter/form'
import { debounce } from '@alexcarpenter/form/debounce'
const $form = form({ username: '' })
const username = debounce($form.field('username'), 300)
username.set('alex')
username.flush() // writes immediatelyUse the path helper for nested values. The core form itself uses top-level field names.
import { form } from '@alexcarpenter/form'
import { path } from '@alexcarpenter/form/path'
const $form = form({ user: { email: '' } })
const email = path($form, 'user.email')
email.set('[email protected]')Use the array helper to update array-valued fields:
import { form } from '@alexcarpenter/form'
import { array } from '@alexcarpenter/form/array'
const $form = form({ items: [] })
const items = array($form, 'items')
items.push({ title: '' })
items.insert(0, { title: 'First' })
items.replace(0, { title: 'Updated' })
items.remove(0)It also provides move() and swap().
Create a derived store for a slice of form state. This is useful when a component should re-render only for one value:
import { form } from '@alexcarpenter/form'
import { select } from '@alexcarpenter/form/select'
const $form = form({ email: '' })
const $canSubmit = select($form, form => form.canSubmit)
const $email = select($form, form => form.values.email)Log field changes and submissions while developing:
import { form } from '@alexcarpenter/form'
import { debug } from '@alexcarpenter/form/debug'
const $form = form({ email: '' })
const stop = debug($form, { name: 'login' })
stop()Install the Nano Stores React binding separately:
pnpm add @nanostores/reactThen connect the form store to React with useStore:
import { useStore } from '@nanostores/react'
import { select } from '@alexcarpenter/form/select'
import { $login } from './stores/login'
const $canSubmit = select($login, form => form.canSubmit)
export function LoginForm() {
const canSubmit = useStore($canSubmit)
const email = useStore($login.field('email').$state)
return <form onSubmit={$login.handleSubmit}>
<input
value={email.value}
onBlur={() => $login.field('email').handleBlur()}
onInput={event => $login.field('email').handleChange(event.currentTarget.value)}
/>
{email.errors[0]}
<button disabled={!canSubmit}>Submit</button>
</form>
}Clone the repository, install its dependencies, and run the complete check suite:
git clone https://github.com/alexcarpenter/form.git
cd form
pnpm install
pnpm testThe test command runs linting, runtime tests, type checks, and bundle-size checks.
MIT © Alex Carpenter