Working with Forms
DelightStack provides a Form component that handles validation, error display, and submission state management. It integrates with Standard Schema — a universal interface supported by Zod, Valibot, ArkType, and other validation libraries.
Basic Form Setup
Section titled “Basic Form Setup”Wrap your form fields in a Form component and pass a schema for validation:
<script> import { Form, Input, Button } from '@delightstack/components'; import { z } from 'zod';
const schema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email'), });
let data = $state({ name: '', email: '' });
async function handleSubmit({ data, is_valid }) { if (!is_valid) return; await fetch('/api/user', { method: 'POST', body: JSON.stringify(data), }); }</script>
<Form {schema} bind:data onsubmit={handleSubmit}> <Input name="name" label="Name" /> <Input name="email" label="Email" type="email" /> <Button type="submit">Create Account</Button></Form>How It Works
Section titled “How It Works”The Form component creates a form context that child components (Input, Select, Checkbox, Radio, Toggle, Range, Rating) consume automatically. When a field has a name prop, it registers with the form context and:
- Reads its value from
data[name] - Writes changes back to
data[name] - Displays errors from the validation result for that field
- Reports touched state when the user interacts with it
You do not need to wire up bind:value or error display manually — the form context handles it.
Choosing a Validation Library
Section titled “Choosing a Validation Library”The Form component accepts any validator that implements the Standard Schema interface. Here is the same validation written in three libraries:
import { z } from 'zod';
const schema = z.object({ name: z.string().min(2, 'Name is required'), email: z.string().email('Invalid email'), age: z.number().min(18, 'Must be 18 or older'),});import * as v from 'valibot';
const schema = v.object({ name: v.pipe(v.string(), v.minLength(2, 'Name is required')), email: v.pipe(v.string(), v.email('Invalid email')), age: v.pipe(v.number(), v.minValue(18, 'Must be 18 or older')),});import { type } from 'arktype';
const schema = type({ name: 'string>=2', email: 'string.email', age: 'number>=18',});All three produce identical behavior when passed to the Form component’s schema prop.
Entity-Backed Forms (Database Tables)
Section titled “Entity-Backed Forms (Database Tables)”If you define your data with Database.table() from @delightstack/database, the entire form — values, validation, submission, and saving state — wires itself up from the entity:
<script> import { Form, Input, Select, Button } from '@delightstack/components';
const { data } = $props(); const { db } = $derived(data);
// Existing entity → edit form. Omit the id (db.entity('person')) → create form. const person = $derived(db.entity('person', page.params.person_id)); const field = $derived(person.form.field);</script>
<Form entity={person} onsaved={() => goto('/people')}> <Input {...field.name} /> <Input {...field.email} /> <Select {...field.relationship} clearable /> <Button type="submit">Save</Button></Form>No bind:value, no schema, no submit handler, no saving state:
- Values flow through the form context — each field reads and writes
person.valuedirectly (a field with anameand no explicitvaluebecomes context-driven). - Validation runs per field via the
parsevalidator included in each spread, on blur and on submit. Only the fields actually rendered are validated, so partial forms just work. - Submission: submit validates, normalizes each field through its
parse(so''becomesundefinedand schema transforms apply), then callsperson.save()— which creates when the entity has no id and updates otherwise.onsavedfires on success. - State: the form derives
is_dirtyfromperson.has_changes, its submitting state fromperson.saving(submitButtons auto-disable and spin), and reset restores the last server values viaperson.reset().
The entity prop is structural — any object with value and save() works (see FormEntity in the Form docs), so you can hand it a custom store too.
Every table also exposes the pieces individually when you want manual control:
table.form.field.<name>(also onentity.form.field) — props to spread onto a field component:name,type,label,placeholder,description,required,readonly, length/value constraints, enumoptions, arraymultiple, and aparsevalidator.table.form.schema— a Standard Schema validator covering all of the table’s editable fields, for theFormcomponent’sschemaprop (it resolves dot-notation field names against nested data, so it validates both flat form records and entity values).
With the table defined as:
export const personTable = Database.table('person', (s) => ({ id: s.primaryKey(), name: s.string().min(1).max(100).label('Full name'), email: s.string().email().optional(), relationship: s.enum(['parent', 'child', 'friend']).optional(),}));field.email spreads as { name: 'email', type: 'email', label: 'Email', parse } — the input type, label, validation, and value binding all come from the one schema definition.
How field props are derived
Section titled “How field props are derived”| Schema builder | Effect on the spread props |
|---|---|
s.string().email() | type="email" (likewise .url(), .date(), .datetime(), .time(), .color(), .password(), .phone()) |
s.string().textarea() | type="textarea" |
.label('...') | label — auto-derived from the field name when omitted (first_name → “First name”) |
.placeholder('...') | placeholder |
.description('...') | description text below the input |
.optional() / .default(...) | the field is not required (fields inside an optional object are also not required) |
.readonly() | readonly |
.min() / .max() | minlength/maxlength on strings, min/max on numbers |
s.enum([...]) | options as { value, label }[], ready for Select — pass { value, label } pairs to s.enum() for custom labels |
s.array(...) | multiple (chips mode on Input, multi-select on Select) |
s.boolean() | type: 'boolean' — spread onto Checkbox or Toggle (not Input) |
s.boolean().optional() | tristate: true — null/undefined mean “unanswered” and display as indeterminate |
s.boolean().default(...) | default_checked — an empty draft displays (and saves) the default |
Primary keys, foreign keys, and derived fields are excluded from form.field and form.schema — they are not user-editable.
Tri-state booleans
Section titled “Tri-state booleans”An optional boolean with no default is genuinely tri-state: true / false / NULL-meaning-unanswered (optional fields are stored as NULL in SQLite). The form layer surfaces that:
- On
Checkbox, an unanswered value displays as indeterminate; clicking resolves it to checked (matching the browser’s native indeterminate-checkbox behavior). The user can’t click back to “unanswered”. - On
Toggle, the spread enables the three-stop track — the unanswered value is the middle stop, and the user can cycle or drag back to it (writingnull). Use Toggle when “no answer” must be re-enterable.
Defaulted booleans (.default(true)) are not tri-state: an empty draft displays the default, and submitting fills the default into the saved data — so what the user sees always matches what gets stored.
Field Validation Without a Form
Section titled “Field Validation Without a Form”Sometimes a whole Form is more than you need — a single setting input, an inline rename field. The Input component accepts a parse prop (the same function included in every table.form.field spread):
<script> import { Input } from '@delightstack/components'; import { personTable } from '$lib/schema';</script>
<Input {...personTable.form.field.email} bind:value={email} />Standalone, the input runs parse when it loses focus and shows the thrown error’s message below the field. While the field is errored it re-validates on every keystroke, so the message clears the moment the value is fixed — without nagging the user before they finish typing.
How Form and Field Validation Work Together
Section titled “How Form and Field Validation Work Together”Validation can live in two places, and they are designed not to conflict:
- Form-level schema — the
Formcomponent’sschemaprop validates the wholedataobject. - Field-level
parse— validates a single field’s value.
Inside a Form, an Input never runs parse itself. It registers the function with the form, and the form runs all field validators together with the form schema on its validate_on timing. The merge rule is simple:
- If both produce an error for the same field, the form schema’s message wins.
- Fields the form schema does not cover keep their field-level error.
- One error per field is ever shown.
When the form schema is table.form.schema, the two layers are literally the same validation functions, so they always agree.
Validation Modes
Section titled “Validation Modes”The validate_on prop controls when fields are validated:
| Mode | Behavior |
|---|---|
'change' | Validate immediately as the user types or changes a value |
'blur' | Validate when the user leaves the field (default) |
'submit' | Only validate when the form is submitted |
<!-- Validate as the user types --><Form {schema} bind:data validate_on="change" onsubmit={handleSubmit}> ...</Form>
<!-- Validate only on form submission --><Form {schema} bind:data validate_on="submit" onsubmit={handleSubmit}> ...</Form>Error Display
Section titled “Error Display”When validation fails, each field automatically displays its error message below the input. The error is associated via aria-describedby for screen reader accessibility.
Errors are only shown for fields that have been touched (interacted with by the user), so a freshly loaded form does not show a wall of errors.
On submit, all fields are marked as touched and all errors become visible.
Form Context
Section titled “Form Context”The Form component provides a context object that child components read. You can also access it programmatically in your own components:
import type { FormContext } from '@delightstack/components';The context provides:
| Property | Type | Description |
|---|---|---|
data | Record<string, unknown> | Current form data |
errors | Record<string, string> | Validation errors by field name |
touched | Record<string, boolean> | Which fields have been interacted with |
is_dirty | boolean | Whether any field has changed |
is_submitting | boolean | Whether submission is in progress |
is_valid | boolean | Whether all fields pass validation |
disabled | boolean | Whether the form is disabled |
validate_on | 'change' | 'blur' | 'submit' | Current validation mode |
And these methods:
| Method | Description |
|---|---|
setValue(name, value) | Update a field’s value |
setTouched(name) | Mark a field as touched |
validateField(name) | Validate a single field |
register(name, el, validator?) | Register a field element and optional field-level validator |
unregister(name) | Unregister a field |
Form Props
Section titled “Form Props”| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, unknown> | {} | Form data object (bindable) |
schema | StandardSchema | — | Standard Schema validator |
validate_on | 'change' | 'blur' | 'submit' | 'blur' | When to validate fields |
disabled | boolean | false | Disable the entire form |
reset_on_submit | boolean | false | Reset to initial values after successful submit |
dense | boolean | false | Compact spacing between fields |
comfortable | boolean | false | Relaxed spacing between fields |
Form Events
Section titled “Form Events”| Event | Detail | Description |
|---|---|---|
onsubmit | { data, is_valid } | Called on form submission |
onchange | { data, errors } | Called when form data changes |
onerror | { errors } | Called when validation fails on submit |
Complete Example
Section titled “Complete Example”Here is a registration form with multiple field types, validation, and user feedback:
View code
<script> import { Form, Input, Select, Checkbox, Toggle, Rating, Range, Radio, RadioGroup, Fieldset, Button, Toaster, toast, } from '@delightstack/components'; import { z } from 'zod';
const schema = z.object({ first_name: z.string().min(1, 'First name is required'), last_name: z.string().min(1, 'Last name is required'), email: z.string().email('Please enter a valid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), bio: z.string().optional(), role: z.string().min(1, 'Please select a role'), interests: z.array(z.string()).default([]), skill: z.number().min(1, 'Please rate your skill level'), experience_years: z.number().default(0), plan: z.string().min(1, 'Please choose a plan'), newsletter: z.boolean().default(false), terms: z.boolean().refine((v) => v, 'You must accept the terms'), });
// The schema is the single source of truth for the data shape. let data: z.infer<typeof schema> = $state({ first_name: '', last_name: '', email: '', password: '', bio: '', role: '', interests: [], skill: 0, experience_years: 0, plan: '', newsletter: false, terms: false, });
const role_options = [ { value: 'developer', label: 'Developer' }, { value: 'designer', label: 'Designer' }, { value: 'manager', label: 'Product Manager' }, { value: 'other', label: 'Other' }, ];
const interest_options = [ { value: 'frontend', label: 'Frontend' }, { value: 'backend', label: 'Backend' }, { value: 'design', label: 'Design' }, { value: 'devops', label: 'DevOps' }, ];
async function handleSubmit({ data }) { await fetch('/api/user', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); toast.success('Account created successfully!'); }
function handleError() { toast.error('Please fix the errors above.'); }</script>
<Toaster />
<Form bind:data {schema} validate_on="blur" reset_on_submit onsubmit={handleSubmit} onerror={handleError}> <Fieldset label="Personal Information" bordered> <Input name="first_name" label="First name" /> <Input name="last_name" label="Last name" /> <Input name="email" label="Email" type="email" /> <Input name="password" label="Password" type="password" description="At least 8 characters" /> <Input name="bio" label="Bio" type="textarea" placeholder="Tell us a little about yourself…" /> </Fieldset>
<Fieldset label="Profile" bordered> <Select name="role" label="Role" placeholder="Select your role" options={role_options} /> <Select name="interests" label="Interests" multiple clearable placeholder="Pick any that apply" options={interest_options} /> <!-- Rating has no label prop, so wrap it --> <label>Skill level <Rating name="skill" /></label> <Range name="experience_years" label="Years of experience" min={0} max={40} show_value /> </Fieldset>
<Fieldset label="Preferences" bordered> <RadioGroup name="plan" label="Plan"> <Radio value="free" label="Free" /> <Radio value="pro" label="Pro" /> <Radio value="enterprise" label="Enterprise" /> </RadioGroup> <Toggle name="newsletter" label="Subscribe to the newsletter" /> <Checkbox name="terms" label="I agree to the terms and conditions" /> </Fieldset>
<div style="display: flex; gap: 1rem;"> <Button type="reset" ghost>Reset</Button> <Button type="submit">Create Account</Button> </div></Form>Using Fields Without a Form
Section titled “Using Fields Without a Form”Every form component works independently when used without a parent Form:
<script> import { Input, Select, Toggle } from '@delightstack/components';
let search = $state(''); let sort = $state('newest'); let compact = $state(false);</script>
<Input label="Search" bind:value={search} placeholder="Search..." />
<Select label="Sort by" bind:value={sort} options={[ { value: 'newest', label: 'Newest First' }, { value: 'oldest', label: 'Oldest First' }, { value: 'name', label: 'Name' }, ]}/>
<Toggle label="Compact view" bind:value={compact} />In this case, you manage state with bind:value and handle validation yourself.