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 })})No bundler required. Drop the CDN script and use window.Forms.newForm({ id, options }).
<form id="contact-form"> <input name="email" type="email" /> <button type="submit">Send</button></form>
<script src="https://unpkg.com/@samline/forms@2.7.2/dist/browser/global.global.js"></script><script> const contact = window.Forms.newForm({ id: 'contact-form', options: { validators: { email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ } } } })
contact.onSubmit(async (_form, _data, formData) => { await fetch('/api/contact', { method: 'POST', body: formData }) })</script>Recipe: Login form with fetch
Section titled “Recipe: Login form with fetch”<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() }})Recipe: Multi-step / wizard
Section titled “Recipe: Multi-step / wizard”<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.
Recipe: Autosave drafts
Section titled “Recipe: Autosave drafts”<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 }))Recipe: Dynamic fields added at runtime
Section titled “Recipe: Dynamic fields added at runtime”<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.
Recipe: Manual-only validation
Section titled “Recipe: Manual-only validation”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.
Recipe: SPA cleanup on route change
Section titled “Recipe: SPA cleanup on route change”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.
Recipe: Sync state into a view layer
Section titled “Recipe: Sync state into a view layer”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.
Recipe: Confirm a password with sameAs
Section titled “Recipe: Confirm a password with sameAs”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 tophone_displayedon first run and appends a hidden<input type="hidden" name="phone">that holds the raw value. The hidden carriesdata-formatter-raw-for="phone"so the controller can identify it as owned and remove it ondestroy(). - 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 survivedestroy(); mirrors created byformat()are removed. - If you prefer to pre-author the visible with the display name (e.g.
<input name="phone_displayed" />) instead of lettingformat()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.