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() creates a type-safe form instance from any SdkMethod — whether it’s a generated SDK mutation, a service action, or a custom endpoint. It provides reactive field state, schema validation, progressive enhancement attributes (action, method), and per-field error tracking.
When the SDK method carries a validation schema in its metadata (as generated methods do), form() picks it up automatically. For custom methods without metadata, pass a schema in the options. The Result type is handled internally — when the method returns { ok: false }, field errors are extracted and mapped to the form fields.
import { form } from '@vertz/ui/form';
function CreateTaskForm({ onSuccess }: { onSuccess: () => void }) {
const taskForm = form(api.tasks.create, {
onSuccess,
});
return (
<form action={taskForm.action} method={taskForm.method} onSubmit={taskForm.onSubmit}>
<input name={taskForm.fields.title} />
{taskForm.title.error && <span>{taskForm.title.error}</span>}
<textarea name={taskForm.fields.description} />
{taskForm.description.error && <span>{taskForm.description.error}</span>}
<button type="submit" disabled={taskForm.submitting}>
Create
</button>
</form>
);
}
Signature
function form<TBody, TResult>(
sdkMethod: SdkMethod<TBody, TResult>,
options?: FormOptions<TBody, TResult>,
): FormInstance<TBody, TResult>;
The returned instance has two kinds of properties: reserved form properties and per-field accessors.
| Property | Type | Description |
|---|
action | string | URL for the form’s action attribute (progressive enhancement) |
method | string | HTTP method for the form’s method attribute |
onSubmit | (e: Event) => Promise<void> | Submit handler — validates, then calls the SDK method |
submitting | boolean | true while the submission is in flight |
dirty | boolean | true if any field has been modified |
valid | boolean | true if all fields pass validation |
fields | FieldNames<TBody> | Object mapping each field name to itself — use for name attributes |
reset() | () => void | Reset all fields to initial values |
submit() | (formData?: FormData) => Promise<void> | Programmatic submit |
setFieldError() | (field: FieldPath<TBody>, message: string) => void | Set a custom error on a field (supports dot-paths for nested fields) |
Per-field accessors
Access any field by name on the form instance (e.g., taskForm.title). Each field is a FieldState:
| Property | Type | Description |
|---|
value | T | Current field value |
error | string | undefined | Validation error message |
dirty | boolean | true if the field has been modified |
touched | boolean | true if the field has been focused and blurred |
setValue() | (value: T) => void | Update value and mark dirty |
reset() | () => void | Reset to initial value, clear error/dirty/touched |
| Option | Type | Default | Description |
|---|
schema | FormSchema<TBody> | — | Validation schema (auto-inferred from SDK metadata when available) |
initial | DeepPartial<TBody> | () => DeepPartial<TBody> | — | Initial field values (supports nested objects) |
onSuccess | (result: TResult) => void | — | Callback after successful submission |
onError | (errors: Record<string, string>) => void | — | Callback on validation or submission error |
resetOnSuccess | boolean | false | Reset fields after successful submission |
revalidateOn | 'blur' | 'change' | 'submit' | 'blur' | When to re-validate fields with errors after first submission |
Before validation, form() coerces each FormData string into the type declared by the body schema:
s.boolean() — checkbox checked → true; absent (unchecked) → false; explicit "false"/"0"/"off" → false.
s.number() / s.bigint() — numeric strings → numbers; empty strings dropped so the field is treated as missing (lets optional() validate).
s.date() — parseable strings → Date.
s.string() — never coerced (even when the value looks numeric).
s.array(<primitive>) — multiple values for the same name produce an array of the leaf type.
Use plain s.boolean() / s.number() / s.date() in form schemas — s.coerce.* is not required. See the Forms guide — FormData coercion for the full table.
Progressive enhancement
The action and method properties enable the form to work without JavaScript. When JS is available, onSubmit intercepts the submission and handles it client-side with validation.
<form action={taskForm.action} method={taskForm.method} onSubmit={taskForm.onSubmit}>
{/* Works with and without JS */}
</form>
For search/filter forms that need to react to every input change without submission, use <form onChange> instead of (or alongside) form(). The handler receives a FormValues snapshot of all current form values, and individual inputs can use debounce={ms} to delay the callback.
See the Forms guide — Form-level onChange for usage, debounce, and examples.
Types
interface FormOptions<TBody, TResult> {
schema?: FormSchema<TBody>;
initial?: DeepPartial<TBody> | (() => DeepPartial<TBody>);
onSuccess?: (result: TResult) => void;
onError?: (errors: Record<string, string>) => void;
resetOnSuccess?: boolean;
revalidateOn?: 'blur' | 'change' | 'submit';
}
// Types below show the developer-facing API — the compiler auto-unwraps
// reactive properties, so you use them as plain values in JSX and templates.
type FormInstance<TBody, TResult> = FormBaseProperties<TBody> & NestedFieldAccessors<TBody>;
interface FormBaseProperties<TBody> {
action: string;
method: string;
onSubmit: (e: Event) => Promise<void>;
reset: () => void;
setFieldError: (field: FieldPath<TBody>, message: string) => void;
submit: (formData?: FormData) => Promise<void>;
submitting: boolean;
dirty: boolean;
valid: boolean;
fields: FieldNames<TBody>;
}
interface FieldState<T = unknown> {
value: T;
error: string | undefined;
dirty: boolean;
touched: boolean;
setValue: (value: T) => void;
reset: () => void;
}
// Recursive field accessors — nested objects get both FieldState
// and nested accessors (e.g., `form.address.city.error`).
// Simplified — the real type also handles arrays (numeric index access),
// built-in objects (Date, File, Blob treated as leaves), and detects
// reserved field name conflicts at compile time.
type NestedFieldAccessors<T> = {
[K in keyof T]: T[K] extends Record<string, unknown>
? FieldState<T[K]> & NestedFieldAccessors<T[K]>
: FieldState<T[K]>;
};
// Recursive dot-path type for nested fields (e.g., 'address.city')
type FieldPath<T, Prefix extends string = ''> =
| `${Prefix}${keyof T & string}`
| {
[K in keyof T & string]: T[K] extends Record<string, unknown>
? FieldPath<T[K], `${Prefix}${K}.`>
: never;
}[keyof T & string];
// Maps each field name to itself — for type-safe `name` attributes
type FieldNames<TBody> = { readonly [K in keyof TBody & string]: K };
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: T[K] extends Record<string, unknown>
? DeepPartial<T[K]>
: T[K];
};
interface FormSchema<T> {
parse(data: unknown): { ok: true; data: T } | { ok: false; error: unknown };
}