Skip to content

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.

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>

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:

  1. Reads its value from data[name]
  2. Writes changes back to data[name]
  3. Displays errors from the validation result for that field
  4. 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.

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'),
});

All three produce identical behavior when passed to the Form component’s schema prop.

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.value directly (a field with a name and no explicit value becomes context-driven).
  • Validation runs per field via the parse validator 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 '' becomes undefined and schema transforms apply), then calls person.save() — which creates when the entity has no id and updates otherwise. onsaved fires on success.
  • State: the form derives is_dirty from person.has_changes, its submitting state from person.saving (submit Buttons auto-disable and spin), and reset restores the last server values via person.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 on entity.form.field) — props to spread onto a field component: name, type, label, placeholder, description, required, readonly, length/value constraints, enum options, array multiple, and a parse validator.
  • table.form.schema — a Standard Schema validator covering all of the table’s editable fields, for the Form component’s schema prop (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.

Schema builderEffect 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: truenull/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.

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

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:

  1. Form-level schema — the Form component’s schema prop validates the whole data object.
  2. 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.

The validate_on prop controls when fields are validated:

ModeBehavior
'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>

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.

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:

PropertyTypeDescription
dataRecord<string, unknown>Current form data
errorsRecord<string, string>Validation errors by field name
touchedRecord<string, boolean>Which fields have been interacted with
is_dirtybooleanWhether any field has changed
is_submittingbooleanWhether submission is in progress
is_validbooleanWhether all fields pass validation
disabledbooleanWhether the form is disabled
validate_on'change' | 'blur' | 'submit'Current validation mode

And these methods:

MethodDescription
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
PropTypeDefaultDescription
dataRecord<string, unknown>{}Form data object (bindable)
schemaStandardSchemaStandard Schema validator
validate_on'change' | 'blur' | 'submit''blur'When to validate fields
disabledbooleanfalseDisable the entire form
reset_on_submitbooleanfalseReset to initial values after successful submit
densebooleanfalseCompact spacing between fields
comfortablebooleanfalseRelaxed spacing between fields
EventDetailDescription
onsubmit{ data, is_valid }Called on form submission
onchange{ data, errors }Called when form data changes
onerror{ errors }Called when validation fails on submit

Here is a registration form with multiple field types, validation, and user feedback:

Personal Information
Profile
Skill level
0
0
Preferences
Plan
Free
Pro
Enterprise
I agree to the terms and conditions
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>

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.