Documentation Index
Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
Use this file to discover all available pages before exploring further.
form() connects an HTML form to any endpoint with client-side validation, per-field error tracking, and progressive enhancement. It works with generated SDK methods (entity CRUD), custom service endpoints, or any function that matches the SdkMethod interface — forms are not limited to entities.
Basic usage
import { form } from '@vertz/ui';
import { api } from '../client';
export function CreateTaskForm({ onSuccess }: CreateTaskFormProps) {
const taskForm = form(api.tasks.create, { onSuccess });
return (
<form action={taskForm.action} method={taskForm.method} onSubmit={taskForm.onSubmit}>
<input name={taskForm.fields.title} placeholder="Task title" />
{taskForm.title.error && <span>{taskForm.title.error}</span>}
<button type="submit" disabled={taskForm.submitting}>
Create
</button>
</form>
);
}
When using a generated SDK method, form() automatically extracts the validation schema from the
method’s metadata — no need to pass schema manually. You only need to provide a schema
explicitly when using a custom (non-generated) SDK method.
How it works
form() takes an SDK method (from your typed API client) and optional config
- On submit, it validates against the schema before sending the request
- Validation errors are available per-field via
taskForm.fieldName.error
- On success, calls
onSuccess with the API response
- On server error, sets a
_form error
Field access
Access per-field state via the form instance using the field name:
const taskForm = form(api.tasks.create, { schema });
// Per-field reactive properties
taskForm.title.error; // Validation error message (signal)
taskForm.title.value; // Current value (signal)
taskForm.title.dirty; // Changed from initial value (signal)
taskForm.title.touched; // User has focused and blurred (signal)
// Methods
taskForm.title.setValue('New title');
taskForm.title.reset();
All field properties are signals — they auto-unwrap in JSX:
// In JSX — no .value needed
<span>{taskForm.title.error}</span>
<button disabled={taskForm.submitting}>Submit</button>
Validation
Schema validation
Pass a schema to validate before submission:
const taskForm = form(api.tasks.create, {
schema: createTaskSchema,
});
The schema must implement a parse method:
interface FormSchema<T> {
parse(data: unknown): { ok: true; data: T } | { ok: false; error: unknown };
}
This is compatible with @vertz/schema parsers. Validation errors are automatically mapped to per-field errors.
Manual field errors
Set errors programmatically:
taskForm.setFieldError('title', 'Title already exists');
FormData only carries strings. form() coerces each value to the type declared by the body schema before validation runs, so plain s.boolean(), s.number(), s.bigint(), and s.date() work directly on <input> values — no s.coerce.* workaround needed.
const taskSchema = s.object({
title: s.string(),
priority: s.number(),
done: s.boolean(),
dueDate: s.date().optional(),
});
const taskForm = form(api.tasks.create, { schema: taskSchema });
| Schema leaf | FormData input | Coerced value |
|---|
s.boolean() | checkbox checked (any non-empty string) | true |
s.boolean() | checkbox absent (unchecked) | false |
s.boolean() | explicit "false", "0", "off" | false |
s.number() | "42" | 42 |
s.number() | "" (empty) | dropped — field is treated as missing (lets optional() validate) |
s.bigint() | "9007199254740993" | 9007199254740993n |
s.date() | "2026-04-18" | new Date("2026-04-18") |
s.string() | "42" | "42" (never coerced) |
s.array(s.string()) | repeated name="tags" checkboxes | string[] from all selected values |
The same coercion runs on blur/change re-validation, so live field errors and submit errors agree.
Coercion only applies to leaves where the schema declares a primitive type. Arrays of objects fall back to FormData’s dotted-index parsing without per-leaf coercion — file uploads via s.instanceof(File) are unchanged.Top-level .refine() and .superRefine() are walked through automatically. Other top-level wrappers (.transform(), .pipe(), .catch(), .brand(), .readonly()) currently disable coercion — wrap your s.object(...) with these only at the field level for now.
taskForm.submitting; // true while the API call is in progress
taskForm.dirty; // true if any field has changed from initial values
taskForm.valid; // true if no field errors exist
Initial values
Pre-populate fields with initial values:
const editForm = form(api.tasks.update, {
schema: updateTaskSchema,
initial: {
title: task.title,
description: task.description,
},
});
Or use a function for dynamic initial values:
const editForm = form(api.tasks.update, {
schema: updateTaskSchema,
initial: () => ({
title: task.data?.title ?? '',
}),
});
Callbacks
const taskForm = form(api.tasks.create, {
schema: createTaskSchema,
// Called with the API response on success
onSuccess: (result) => {
navigate({ to: '/tasks/:id', params: { id: result.id } });
},
// Called with field error map on validation failure
onError: (errors) => {
console.log('Validation failed:', errors);
},
// Reset all fields after successful submission
resetOnSuccess: true,
});
Programmatic submission
Submit without a form element:
await taskForm.submit();
// Or with custom FormData
const formData = new FormData();
formData.set('title', 'My task');
await taskForm.submit(formData);
Progressive enhancement
The action and method properties enable forms that work without JavaScript:
<form
action={taskForm.action}
method={taskForm.method}
onSubmit={taskForm.onSubmit}
>
Without JS, the form submits directly to the API endpoint. With JS, onSubmit intercepts and handles it client-side with validation.
Field revalidation
By default, fields with errors are revalidated when the user blurs (leaves) the field — giving immediate feedback without validating fields the user hasn’t interacted with yet.
Control this with the revalidateOn option:
const taskForm = form(api.tasks.create, {
schema: createTaskSchema,
revalidateOn: 'blur', // default — revalidate on blur
});
| Value | Behavior |
|---|
'blur' (default) | Re-validates errored fields when the user blurs them |
'change' | Re-validates errored fields on every input or change event |
'submit' | No re-validation between submissions — errors only update on submit |
Revalidation only activates after the first form submission. Before the user submits, no field-level validation feedback is shown. This avoids premature error messages on untouched forms.
Only fields that already have errors are re-validated — fields without errors are not checked until the next submission.
Reset
Reset all fields to their initial values:
<button type="button" onClick={() => taskForm.reset()}>
Cancel
</button>
form() works with any function that matches the SdkMethod interface — it is not limited to entity CRUD methods. Use it for search filters, settings panels, contact forms, or any scenario where you need validation, field state tracking, and progressive enhancement.
- Search / filter forms — validate filter inputs, track dirty state, submit to a custom endpoint
- Settings panels — schema-validated preferences with per-field errors and reset
- Contact / feedback forms — progressive-enhanced forms that submit to a service action
- Any custom endpoint — anything exposed via
service() or a manual SdkMethod
Using with service actions
Service actions from the generated SDK work the same as entity methods:
import { form } from '@vertz/ui';
import { api } from '../client';
export function ContactForm({ onSuccess }: { onSuccess: () => void }) {
const contactForm = form(api.support.sendMessage, {
onSuccess,
resetOnSuccess: true,
});
return (
<form action={contactForm.action} method={contactForm.method} onSubmit={contactForm.onSubmit}>
<input name={contactForm.fields.email} type="email" placeholder="Your email" />
{contactForm.email.error && <span>{contactForm.email.error}</span>}
<textarea name={contactForm.fields.message} placeholder="How can we help?" />
{contactForm.message.error && <span>{contactForm.message.error}</span>}
<button type="submit" disabled={contactForm.submitting}>
Send
</button>
</form>
);
}
Using with a custom SdkMethod
When you need a form for an endpoint that isn’t generated by codegen, create an SdkMethod manually. You must provide a schema since there’s no metadata to infer it from:
import { form } from '@vertz/ui';
import type { SdkMethod } from '@vertz/ui';
import { s } from '@vertz/schema';
interface SearchFilters {
query: string;
status: 'all' | 'active' | 'archived';
sortBy: 'date' | 'name';
}
const searchMethod: SdkMethod<SearchFilters, SearchResults> = Object.assign(
async (body: SearchFilters) => {
const res = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return { ok: true as const, data };
},
{ url: '/api/search', method: 'POST' },
);
const searchSchema = s.object({
query: s.string(),
status: s.enum(['all', 'active', 'archived']),
sortBy: s.enum(['date', 'name']),
});
export function SearchForm({ onResults }: { onResults: (r: SearchResults) => void }) {
const searchForm = form(searchMethod, {
schema: searchSchema,
initial: { query: '', status: 'all', sortBy: 'date' },
onSuccess: onResults,
});
return (
<form action={searchForm.action} method={searchForm.method} onSubmit={searchForm.onSubmit}>
<input name={searchForm.fields.query} placeholder="Search..." />
{searchForm.query.error && <span>{searchForm.query.error}</span>}
<select name={searchForm.fields.status}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
<select name={searchForm.fields.sortBy}>
<option value="date">Date</option>
<option value="name">Name</option>
</select>
<button type="submit" disabled={searchForm.submitting}>
Search
</button>
</form>
);
}
Settings panel example
import { form, query } from '@vertz/ui';
import { api } from '../client';
export function SettingsPanel() {
// Fetch current user settings
const currentUser = query(api.users.me());
const settingsForm = form(api.settings.update, {
initial: () => ({
theme: currentUser.data?.theme ?? 'light',
language: currentUser.data?.language ?? 'en',
notifications: currentUser.data?.notifications ?? true,
}),
onSuccess: () => {
/* show success feedback */
},
});
return (
<form
action={settingsForm.action}
method={settingsForm.method}
onSubmit={settingsForm.onSubmit}
>
<select name={settingsForm.fields.theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<select name={settingsForm.fields.language}>
<option value="en">English</option>
<option value="pt">Portuguese</option>
</select>
<button type="submit" disabled={settingsForm.submitting}>
Save
</button>
<button type="button" onClick={() => settingsForm.reset()} disabled={!settingsForm.dirty}>
Discard changes
</button>
</form>
);
}
For simple UI-only state that doesn’t submit to an endpoint (e.g., a local filter toggle), reactive variables are simpler:
export function TaskFilter({ onFilterChange }: TaskFilterProps) {
let status = 'all';
return (
<select
value={status}
onInput={(e) => {
status = e.currentTarget.value;
onFilterChange(status);
}}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="done">Done</option>
</select>
);
}
Use form() when you need validation, per-field error tracking, dirty/submitting state, or progressive enhancement. For pure client-side state without submission, let variables are enough.
<form onChange={handler}> fires a callback with all current form values whenever any child input changes. The handler receives a FormValues object — a flat { [key: string]: string } snapshot — instead of a DOM Event.
import type { FormValues } from '@vertz/ui';
export function SearchForm() {
function handleChange(values: FormValues) {
console.log(values.q, values.status);
}
return (
<form onChange={handleChange}>
<input name="q" debounce={300} placeholder="Search..." />
<select name="status">
<option value="all">All</option>
<option value="active">Active</option>
</select>
</form>
);
}
This is the recommended pattern for search/filter forms where you want to react to every change without wiring individual onInput handlers.
Add debounce={ms} to any <input>, <textarea>, or <select> to delay the onChange callback for that field:
<input name="q" debounce={300} placeholder="Search..." />
<textarea name="notes" debounce={500} />
- Text inputs with
debounce wait for the user to stop typing before firing
- Inputs without
debounce fire immediately (coalesced via microtask batching)
- When an immediate change occurs (e.g., select change), all pending debounce timers are canceled and flushed together
How it works
- The compiler transforms
<form onChange={handler}> into __formOnChange(form, handler) — a delegated event listener on the form
debounce={N} on inputs becomes data-vertz-debounce="N" — the runtime reads this attribute
- Non-debounced events are coalesced via microtask batching (multiple changes in the same tick = one callback)
- The callback receives
FormValues from new FormData(form), giving a point-in-time snapshot
Limitations
- String values only — all values in
FormValues are strings (from FormData serialization)
- Unchecked checkboxes absent — unchecked checkboxes are not included in FormData (standard HTML behavior)
- No multi-value fields — multiple values for the same name (e.g., multi-select) are not supported; only the last value is kept
Escape hatch: raw DOM events
If you need the native DOM change event (e.g., for file inputs or custom behavior), use a ref:
import { ref, onMount } from '@vertz/ui';
export function FileForm() {
const formRef = ref<HTMLFormElement>();
onMount(() => {
formRef.current?.addEventListener('change', (e) => {
// Raw DOM Event — use for file inputs, etc.
});
});
return <form ref={formRef}>...</form>;
}
form() and <form onChange> serve different purposes and can be used together:
form() — handles submission, validation, per-field errors, and server communication
<form onChange> — reacts to input changes in real time (search-as-you-type, live filtering)
export function SearchWithValidation() {
const searchForm = form(api.search.execute, { schema: searchSchema });
function handleLiveFilter(values: FormValues) {
// Update live preview as the user types
updatePreview(values.q);
}
return (
<form
action={searchForm.action}
method={searchForm.method}
onSubmit={searchForm.onSubmit}
onChange={handleLiveFilter}
>
<input name="q" debounce={300} placeholder="Search..." />
<button type="submit">Search</button>
</form>
);
}