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

# Forms

> Type-safe form handling with schema validation

`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

```tsx theme={null}
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>
  );
}
```

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

## 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:

```tsx theme={null}
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:

```tsx theme={null}
// 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:

```tsx theme={null}
const taskForm = form(api.tasks.create, {
  schema: createTaskSchema,
});
```

The schema must implement a `parse` method:

```tsx theme={null}
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:

```tsx theme={null}
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.

```tsx theme={null}
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.

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

## Form-level state

```tsx theme={null}
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:

```tsx theme={null}
const editForm = form(api.tasks.update, {
  schema: updateTaskSchema,
  initial: {
    title: task.title,
    description: task.description,
  },
});
```

Or use a function for dynamic initial values:

```tsx theme={null}
const editForm = form(api.tasks.update, {
  schema: updateTaskSchema,
  initial: () => ({
    title: task.data?.title ?? '',
  }),
});
```

## Callbacks

```tsx theme={null}
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:

```tsx theme={null}
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:

```tsx theme={null}
<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:

```tsx theme={null}
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:

```tsx theme={null}
<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:

```tsx theme={null}
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:

```tsx theme={null}
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

```tsx theme={null}
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:

```tsx theme={null}
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`.

```tsx theme={null}
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:

```tsx theme={null}
<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`:

```tsx theme={null}
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)

```tsx theme={null}
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>
  );
}
```
