Skip to content

Examples

These examples are deliberately copy-pasteable. Each one lives in a self-contained snippet so it’s easy to drop into a project and adapt.

The standard entrypoint — import { form } from '@samline/forms'. Works with Vite, Webpack, Rollup, esbuild, Bun, and any modern bundler.

import { form } from '@samline/forms'
const contact = form('contact-form', {
validators: {
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
}
})
contact.onSubmit(async (_form, _data, formData) => {
await fetch('/api/contact', { method: 'POST', body: formData })
})
<form id="login-form">
<input name="email" type="email" autocomplete="email" required />
<input name="password" type="password" autocomplete="current-password" required />
<button type="submit">Sign in</button>
</form>
import { form } from '@samline/forms'
const login = form('login-form', {
validators: {
email: {
required: { value: true, message: 'Email is required.' },
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Enter a valid email.'
}
},
password: { required: true, minLength: 8 }
}
})
login.onSubmit(async (_element, _data, formData) => {
const response = await fetch('/api/login', {
method: 'POST',
body: formData
})
if (!response.ok) {
login.setErrors({ password: ['Wrong email or password.'] })
return
}
window.location.assign('/dashboard')
})

What this does:

  • Validates with custom messages.
  • Intercepts the submit (default preventDefault: true) so the browser does not navigate.
  • Surfaces a single error on the password field when the API rejects the credentials.

Recipe: Surface server-side validation errors

Section titled “Recipe: Surface server-side validation errors”
import { form } from '@samline/forms'
const profile = form('profile-form', {
autoValidate: false,
clearManualErrorsOnChange: false,
validators: {
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
}
})
profile.onSubmit(async (_element, _data, formData) => {
const response = await fetch('/api/profile', {
method: 'POST',
body: formData
})
if (response.status === 422) {
const { fieldErrors } = (await response.json()) as {
fieldErrors: Record<string, string[]>
}
profile.setErrors(fieldErrors)
return
}
if (response.ok) {
profile.clearErrors()
profile.reset()
}
})
<form id="wizard">
<fieldset data-step="1">
<input name="name" />
<input name="email" type="email" />
</fieldset>
<fieldset data-step="2" hidden>
<input name="address" />
<input name="city" />
</fieldset>
<button type="button" id="next">Next</button>
<button type="submit" id="submit" hidden>Submit</button>
</form>
import { form } from '@samline/forms'
const wizard = form('wizard', {
validators: {
name: { required: true },
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
address: { required: true },
city: { required: true }
}
})
const stepFields: Record<1 | 2, string[]> = {
1: ['name', 'email'],
2: ['address', 'city']
}
let currentStep: 1 | 2 = 1
document.querySelector('#next')?.addEventListener('click', () => {
const ok = wizard.validate(stepFields[currentStep]).isValid
if (!ok) return
currentStep = 2
wizard.element?.querySelector('[data-step="1"]')?.setAttribute('hidden', '')
wizard.element?.querySelector('[data-step="2"]')?.removeAttribute('hidden')
document.querySelector('#next')!.setAttribute('hidden', '')
document.querySelector('#submit')!.removeAttribute('hidden')
})
wizard.onSubmit(async (_element, _data, formData) => {
await fetch('/api/wizard', { method: 'POST', body: formData })
})

Each step validates only its own fields, so the user can progress without being blocked by later fields.

<form id="draft">
<input name="title" />
<textarea name="body"></textarea>
</form>
import { form } from '@samline/forms'
const draft = form('draft', {
autoSubmit: { debounce: 800 }
})
draft.onSubmit(async (_element, _data, formData) => {
await fetch('/api/draft', { method: 'POST', body: formData })
})

For a JSON endpoint, switch the body to JSON.stringify(data) and set the right Content-Type header.

To pause autosave while the user is typing into a “bulk edit” textarea, toggle it explicitly:

const bodyField = draft.getField('body') as HTMLTextAreaElement | null
bodyField?.addEventListener('focus', () => draft.disableAutoSubmit())
bodyField?.addEventListener('blur', () => draft.autoSubmit({ debounce: 800 }))
<form id="builder">
<div id="rows"></div>
<button type="button" id="add">Add row</button>
<button type="submit">Save</button>
</form>
import { form } from '@samline/forms'
const builder = form('builder', {
validators: {
'rows[].name': {
minLength: 1,
each: {
required: { value: true, message: 'Every row needs a name.' }
}
}
}
})
document.querySelector('#add')?.addEventListener('click', () => {
const rows = document.querySelector('#rows')!
const index = rows.childElementCount
const row = document.createElement('div')
row.innerHTML = `
<label>
Name
<input name="rows[].name" />
</label>
<label>
Value
<input name="rows[].value" />
</label>
`
rows.appendChild(row)
})

Validator keys match literal HTML names, not wildcard paths. Reusing rows[].name gives the validator an array once multiple rows exist. The form-subtree MutationObserver clears field lookups and, because the default controller is already validated, re-runs validation when each row is appended.

each validates every repeated input independently, so only an empty row receives css-error and aria-invalid. Public messages remain a flat string[] at state.errors['rows[].name'].

Recipe: Numeric ranges and dependent totals

Section titled “Recipe: Numeric ranges and dependent totals”
const invoice = form('invoice', {
validators: {
amount: {
required: true,
numeric: true,
min: { value: 0, message: 'Amount cannot be negative.' },
max: 100000
},
total: {
dependsOn: ['amount', 'tax'],
validate: ({ value, values }) =>
Number(value) === Number(values.amount) + Number(values.tax)
? null
: 'Total must equal amount plus tax.'
}
}
})

Numeric rules skip empty values, accept only strict finite signed decimals, and include both bounds. dependsOn makes custom cross-field validation reactive after validation is active without adding an error itself.

When you want validation to run only when you ask (not on every change), disable autoValidate and call validate() yourself.

import { form } from '@samline/forms'
const checkout = form('checkout-form', {
autoValidate: false,
validators: {
card: { required: true, minLength: 12 },
expiry: { required: true }
}
})
document.querySelector('#pay')?.addEventListener('click', () => {
const result = checkout.validate()
if (!result.isValid) {
checkout.setErrors({ card: ['Please review your details.'] })
return
}
checkout.element?.requestSubmit()
})

Useful when you want to defer validation until a specific event (e.g. a “Review order” button).

Recipe: Native server-rendered submit (Blade, ERB, classic Rails)

Section titled “Recipe: Native server-rendered submit (Blade, ERB, classic Rails)”
<form id="profile" action="/profile" method="post">
@csrf
<input name="email" type="email" />
<input name="name" />
<button type="submit">Save</button>
</form>
import { form } from '@samline/forms'
const profile = form('profile', {
validators: {
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
}
})
profile.onSubmit(() => {
console.log('client-side hooks ran')
}, false)

When validation passes, the browser performs the native form submit to the action URL. When validation fails, the submit is prevented and the user sees the inline errors.

import { form } from '@samline/forms'
const controllers = new Map<string, ReturnType<typeof form>>()
router.on('/profile', element => {
const controller = form(element, { validators: { email: { required: true } } })
const adapter = mountAddressPicker(controller.getField('address'))
controller.addCleanup(() => adapter.destroy())
controllers.set('profile', controller)
})
router.off('/profile', () => {
controllers.get('profile')?.destroy()
controllers.delete('profile')
})

destroy() is idempotent. Registered cleanups run once in reverse registration order; the function returned by addCleanup() unregisters a cleanup without running it.

import { form } from '@samline/forms'
const signup = form('signup-form', {
validators: {
email: { required: true },
password: { required: true, minLength: 8 }
}
})
signup.subscribe(state => {
const submit = signup.element?.querySelector<HTMLButtonElement>('button[type="submit"]')
if (submit) {
submit.disabled = !state.isValid || state.isSubmitting
submit.textContent = state.isSubmitting ? 'Creating account...' : 'Create account'
}
})

Use subscribe when a single source of truth should drive your UI. isSubmitting remains true while promise-returning handlers from any overlapping valid submissions are pending, and returns to false whether they fulfill or reject.

import { form } from '@samline/forms'
const signup = form('signup-form', {
validators: {
password: { required: true, minLength: 8 },
password_confirmation: {
required: true,
sameAs: {
value: 'password',
message: 'Passwords do not match.'
}
}
}
})

Declare the rule on the confirmation field so the mismatch has one owner. The controller automatically revalidates it when password changes; do not add reciprocal sameAs rules or watch() calls unless you intentionally want errors on both fields. Empty values are left to required.

Recipe: Format inputs with @samline/formatter

Section titled “Recipe: Format inputs with @samline/formatter”

When you need phone masks, credit-card grouping, numeral delimiters, or any other input formatting, pair @samline/forms with @samline/formatter. The format() and formatAll() methods bind a formatter pipeline to one or many fields, manage the hidden raw mirror, and preserve the caret like cleave.js.

<form id="checkout">
<input name="phone" autocomplete="tel" />
<input name="card" autocomplete="cc-number" />
<input name="amount" inputmode="decimal" />
</form>
import { form } from '@samline/forms'
const checkout = form('checkout', {
formats: {
phone: { type: 'phone', field: 'phone', options: { country: 'MX' } },
card: { type: 'creditCard', field: 'card' },
amount: { type: 'numeral', field: 'amount', options: { prefix: '$' } }
}
})
checkout.onSubmit((_form, _data, formData) => {
// formData carries the raw value at the canonical name and the
// formatted value at the display name. The backend only needs the
// canonical (raw) keys:
// phone = '5512345678'
// phone_displayed = '55 1234 5678'
// card = '4111111111111111'
// card_displayed = '4111 1111 1111 1111'
// amount = '1234'
// amount_displayed= '$1,234'
fetch('/api/checkout', { method: 'POST', body: formData })
})

Things to know:

  • You write the HTML with the canonical name only (e.g. <input name="phone" />). format() renames the visible to phone_displayed on first run and appends a hidden <input type="hidden" name="phone"> that holds the raw value. The hidden carries data-formatter-raw-for="phone" so the controller can identify it as owned and remove it on destroy().
  • Both names are first-class in the controller’s API. getValue('phone') returns the raw, getValue('phone_displayed') returns the formatted, and watchers can target either name. See The mirror convention for the full contract.
  • If you authored your own <input type="hidden" name="<field>"> (or <input data-formatter-raw-for="<field>">) before wiring the controller, format() reuses it instead of duplicating it. Pre-existing mirrors survive destroy(); mirrors created by format() are removed.
  • If you prefer to pre-author the visible with the display name (e.g. <input name="phone_displayed" />) instead of letting format() rename it, format() picks that up and skips the rename. Both authoring styles end up with the same DOM.
  • Use formatAll({ type, field: ['a', 'b', 'c'], options }) to bind the same configuration to several fields in one call. Each field gets its own pair (a + a_displayed, b + b_displayed, c + c_displayed).

See Formatting inputs for readiness timing, prefilled values, cleanup, and distribution differences.