Skip to content

Repository files navigation

Nano Stores Form

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 kB minified 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() //=> false

Install

Requires Node.js 20 or later.

pnpm add nanostores @alexcarpenter/form

Guide

Create a form

Use 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 set

If 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.

Form state

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
//=> }

Validation

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() //=> false

For 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') //=> false

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.

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

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 }

Submit and reset

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 modules

Optional features live in separate entrypoints so they are not included by core imports.

Standard Schema

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)
})

Debounce

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 immediately

Path

Use 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]')

Array

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().

Select

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)

Debug

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()

React

Install the Nano Stores React binding separately:

pnpm add @nanostores/react

Then 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>
}

Development

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 test

The test command runs linting, runtime tests, type checks, and bundle-size checks.

License

MIT © Alex Carpenter

Links

About

Tiny headless forms for Nano Stores

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages