@alexcarpenter/form
v0.1.0
Published
Tiny headless forms for Nano Stores
Downloads
99
Maintainers
Readme
Nano Stores Form
Tiny headless forms for Nano Stores.
- Small. One core entrypoint and no runtime dependencies except Nano Stores.
- Nano Stores native. A form is a store with
get(),listen(),subscribe(), and form actions. - Headless. Use it with React, Preact, Vue, Svelte, Solid, or vanilla JS.
- Flat by design. Field names are top-level keys. Schema adapters and framework helpers can live in separate entrypoints later.
import { form } from '@alexcarpenter/form'
export 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]')
await $login.handleSubmit()Install
pnpm add nanostores @alexcarpenter/formGuide
TanStack-style config
If you prefer the TanStack Form shape, use createForm() with
defaultValues and validators.
import { createForm } from '@alexcarpenter/form'
export const $login = createForm({
defaultValues: {
email: '',
password: ''
},
validators: {
onChange: ({ values }) => ({
email: values.email.includes('@') ? undefined : 'Invalid email'
}),
onChangeAsync: async ({ value }) => {
if (value && !(await api.isEmailAvailable(value))) return 'Email is taken'
},
onSubmit: ({ values }) => ({
password: values.password ? undefined : 'Required'
})
},
onChangeAsyncDebounceMs: 300,
onSubmit: async ({ values }) => {
await api.login(values)
}
})form(defaultValues, opts) is kept as the smaller direct style.
Form state
$form.get()
//=> {
//=> values: { email: '' },
//=> errors: {},
//=> touched: {},
//=> dirty: {},
//=> isSubmitting: false,
//=> isValidating: false,
//=> submitCount: 0,
//=> canSubmit: true
//=> }Validation
A validator receives the form values. Return an object of field errors for form validation.
const $form = form({ email: '' }, {
validate: ({ values }) => ({
email: values.email ? undefined : 'Required'
})
})
await $form.validate()When a single field is validated, field and value are provided. Return the
field error directly.
const $form = form({ email: '' }, {
validate: ({ field, value }) => {
if (field === 'email' && !value) return 'Required'
}
})
await $form.validate('email', 'blur')Validators 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.
const $form = form({ username: '' }, {
validate: async ({ field, value }) => {
if (field === 'username') {
let available = await api.isUsernameAvailable(value)
if (!available) return 'Username is taken'
}
}
})Fields
Fields are created lazily and are backed by the form store.
const email = $form.field('email')
email.set('[email protected]')
await email.blur()
email.get()
//=> {
//=> name: 'email',
//=> value: '[email protected]',
//=> errors: [],
//=> touched: true,
//=> dirty: true
//=> }Submit
const $form = form({ email: '' }, {
validate: ({ values }) => ({
email: values.email ? undefined : 'Required'
}),
onSubmit: async ({ values }) => {
await fetch('/login', {
body: JSON.stringify(values),
method: 'POST'
})
}
})
let ok = await $form.handleSubmit()handleSubmit(event?) prevents the event default when provided, touches all
fields, validates the form, and only calls onSubmit when valid.
$form.resetField('email')
await $form.validateField('email')Optional modules
Optional features live in separate entrypoints so they are not included by core imports.
Standard Schema
Use any validator that supports Standard Schema, such as Zod or Valibot.
import { standardSchema } from '@alexcarpenter/form/standard-schema'
const $form = form({ email: '' }, {
validate: standardSchema(schema)
})Debounce
import { debounce } from '@alexcarpenter/form/debounce'
const username = debounce($form.field('username'), 300)
username.set('alex')
username.cancel()
username.flush()Path
import { path } from '@alexcarpenter/form/path'
const email = path($form, 'user.email')
email.set('[email protected]')Array
import { array } from '@alexcarpenter/form/array'
const items = array($form, 'items')
items.push({ title: '' })
items.insert(0, { title: 'First' })
items.replace(0, { title: 'Updated' })
items.move(0, 1)
items.swap(0, 1)
items.remove(0)Select
Create a small derived store for a slice of form state. Useful for React when a component should re-render only for one value.
import { select } from '@alexcarpenter/form/select'
const $canSubmit = select($form, form => form.canSubmit)
const $email = select($form, form => form.values.email)Debug
import { debug } from '@alexcarpenter/form/debug'
let stop = debug($form, { name: 'login' })React
Use the normal Nano Stores React binding:
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() {
let canSubmit = useStore($canSubmit)
let 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>
}