Skip to main content

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.

form()

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>;

FormInstance

The returned instance has two kinds of properties: reserved form properties and per-field accessors.

Form properties

PropertyTypeDescription
actionstringURL for the form’s action attribute (progressive enhancement)
methodstringHTTP method for the form’s method attribute
onSubmit(e: Event) => Promise<void>Submit handler — validates, then calls the SDK method
submittingbooleantrue while the submission is in flight
dirtybooleantrue if any field has been modified
validbooleantrue if all fields pass validation
fieldsFieldNames<TBody>Object mapping each field name to itself — use for name attributes
reset()() => voidReset all fields to initial values
submit()(formData?: FormData) => Promise<void>Programmatic submit
setFieldError()(field: FieldPath<TBody>, message: string) => voidSet 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:
PropertyTypeDescription
valueTCurrent field value
errorstring | undefinedValidation error message
dirtybooleantrue if the field has been modified
touchedbooleantrue if the field has been focused and blurred
setValue()(value: T) => voidUpdate value and mark dirty
reset()() => voidReset to initial value, clear error/dirty/touched

FormOptions

OptionTypeDefaultDescription
schemaFormSchema<TBody>Validation schema (auto-inferred from SDK metadata when available)
initialDeepPartial<TBody> | () => DeepPartial<TBody>Initial field values (supports nested objects)
onSuccess(result: TResult) => voidCallback after successful submission
onError(errors: Record<string, string>) => voidCallback on validation or submission error
resetOnSuccessbooleanfalseReset fields after successful submission
revalidateOn'blur' | 'change' | 'submit''blur'When to re-validate fields with errors after first submission

FormData coercion

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>

Form-level onChange

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 };
}