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': { required: true }
}
})
document.querySelector('#add')?.addEventListener('click', () => {
const rows = document.querySelector('#rows')!
const index = rows.childElementCount
const row = document.createElement('div')
row.innerHTML = `
<input name="rows[${index}].name" />
<input name="rows[${index}].value" />
`
rows.appendChild(row)
})

The controller’s MutationObserver watches the form subtree for new fields and name / type attribute changes. It clears the field cache, re-syncs visual state, and re-runs validation — so dynamic rows are validated automatically without any extra wiring.

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 => {
controllers.set('profile', form(element, { validators: { email: { required: true } } }))
})
router.off('/profile', () => {
controllers.get('profile')?.destroy()
controllers.delete('profile')
})

destroy() is idempotent — calling it more than once is safe.

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

Use subscribe when a single source of truth should drive your UI (button enabled state, live previews, dependent field visibility, etc.).

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 already contains both:
// phone='55 1234 5678', phoneRaw='5512345678'
// card='4111 1111 1111 1111', cardRaw='4111111111111111'
// amount='$1,234', amountRaw='1234'
fetch('/api/checkout', { method: 'POST', body: formData })
})

Things to know:

  • Each formatted field gets a hidden sibling <input type="hidden" name="<field>Raw"> that the controller creates and owns. The mirror carries data-formatter-raw-for="<field>" so the controller can find and remove it on destroy().
  • If you authored your own <input name="<field>_raw"> (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.
  • Use formatAll({ type, field: ['a', 'b', 'c'], options }) to bind the same configuration to several fields in one call. Each field gets its own hidden mirror (aRaw, bRaw, cRaw).