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() 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

  1. form() takes an SDK method (from your typed API client) and optional config
  2. On submit, it validates against the schema before sending the request
  3. Validation errors are available per-field via taskForm.fieldName.error
  4. On success, calls onSuccess with the API response
  5. 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 coercion

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 leafFormData inputCoerced 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" checkboxesstring[] 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.

Form-level state

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

Forms without entities

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.

When to use form() without entities

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

When NOT to use 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-level onChange

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

Per-input debounce

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

  1. The compiler transforms <form onChange={handler}> into __formOnChange(form, handler) — a delegated event listener on the form
  2. debounce={N} on inputs becomes data-vertz-debounce="N" — the runtime reads this attribute
  3. Non-debounced events are coalesced via microtask batching (multiple changes in the same tick = one callback)
  4. 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>;
}

Interaction with 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>
  );
}