---
title: "Forms"
description: "Schema-driven Vue forms with useTamanForm, codecs, and progressive validation — from @vinicunca/taman-ui/form."
canonical_url: "https://vinicunca.dev/taman/guides/forms"
---
# Forms

> Schema-driven Vue forms with useTamanForm, codecs, and progressive validation — from @vinicunca/taman-ui/form.

Adapted from the package's `src/form/README.md`. The web app wraps this in `apps/web/src/adapter/form.ts` (localized `required` / `selectRequired` rules and a default `calendarDateTimeCodec`).

## Mental model

```text
useTamanForm(options)
  -> Form       Vue component rendered in the template
  -> formApi    controller for values, validation, submission, and schema changes
```

- **Raw values** — what field components use (`CalendarDate`, etc.).
- **Submit values** — what `getValues()` and `handleSubmit` see (ISO strings after a codec).

Without a `codec`, both shapes are the same.

## Setup

Register named rules once at startup (`setupTamanForm`). A rule returns `true` or an error string:

```ts
import { isEmptyFormValue, setupTamanForm } from '@vinicunca/taman-ui/form';

setupTamanForm({
  rules: {
    required(value, _params, context) {
      return isEmptyFormValue(value)
        ? `${context.label ?? context.name} is required`
        : true;
    },
  },
});
```

## Quick start

```vue
<script setup lang="ts">
import { useTamanForm, z } from '@vinicunca/taman-ui/form';

interface ProfileFormValues {
  email: string;
  name: string;
}

const [ProfileForm, formApi] = useTamanForm<ProfileFormValues>({
  handleSubmit(values) {
    console.log(values);
  },
  schema: [
    {
      component: 'Input',
      fieldName: 'name',
      label: 'Name',
      rules: z.string().min(1, 'Name is required'),
    },
    {
      component: 'Input',
      fieldName: 'email',
      label: 'Email',
      rules: z.email('Enter a valid email'),
    },
  ],
});

async function save() {
  const values = await formApi.validateAndSubmit();
  if (values) {
    console.log('Submitted', values);
  }
}
</script>

<template>
  <ProfileForm />
  <button
    type="button"
    @click="save"
  >
    Save
  </button>
</template>
```

Render `<Form />` **before** awaiting API methods that need the runtime form. Those methods wait for the first mount; awaiting them without rendering waits forever.

`z` is a namespace re-export of Zod 4.

## Generics

```ts
useTamanForm<TFormValues, TComponentType, TComponentPropsMap, TSubmitValues>(options);
```

The app adapter fixes component types from `adapter/components.ts` and only asks you for `TFormValues` / `TSubmitValues`.

## Options

| Option | Default | Purpose |
| --- | --- | --- |
| `schema` | `[]` | Fields and groups |
| `commonConfig` | `{}` | Shared field fallbacks |
| `wrapperClass` | `grid-cols-1` | Form grid |
| `compact` | `false` | No reserved message space |
| `disabled` | `false` | Disables fields **and** default actions |
| `showDefaultActions` | `true` | Submit/reset |
| `actionLayout` | `rowEnd` | `inline` \| `newLine` \| `rowEnd` |
| `actionPosition` | `right` | `left` \| `center` \| `right` |
| `submitOnEnter` | `false` |  |
| `submitOnChange` | `false` | Debounced by `changeDebouncedTime` (300 ms) |
| `scrollToFirstError` | `false` |  |
| `codec` | none | Raw ↔ submit |
| `handleSubmit` |  | `(values, rawValues)` |
| `handleReset` |  | Replaces default reset; call `formApi.reset()` yourself if you still want a clear |
| `handleValuesChange` |  | `(rawValues, changedFields, getFormattedValues)` — format lazily |

`commonConfig.disabled` disables fields only.

## Schema

Field names support nested paths (`profile.email`) and arrays (`contacts[0].name`). Wrap `[profile.email]` when the dots are literal.

| Property | Purpose |
| --- | --- |
| `component` | Built-in name, registered name, or Vue component |
| `fieldName` | Value path |
| `defaultValue` | Initial raw value |
| `label` / `description` / `help` / `suffix` | Copy |
| `componentProps` | Object or `(ctx) => props` |
| `formFieldProps` | `validateOn`, `validators`, `asyncDebounceMs` |
| `rules` | Named rule, Zod schema, `null`, or another rule name |
| `dependencies` | Recompute when `triggerFields` change |
| `hide` / `disabled` / `collapsed` / `collapsible` | Visibility |
| `formItemClass` | Grid item classes (`cols-span-2`, …) |

### Dependencies

```ts
{
  fieldName: 'companyName',
  component: 'Input',
  dependencies: {
    triggerFields: ['accountType'],
    resolve: ({ values }) => ({
      show: values.accountType === 'business',
      required: values.accountType === 'business',
      rules: values.accountType === 'business'
        ? z.string().min(1, 'Company name is required')
        : null,
    }),
  },
}
```

Groups (`component: 'Group'` or a `schema` array of fields) share `wrapperClass`. Array fields use `component: 'Array'` with `fieldArray`.

## Validation

Default policy is progressive: no error while typing → blur or submit reveals → further changes revalidate.

```ts
formFieldProps: {
  validateOn: ['blur'], // or ['change'], or both
  asyncDebounceMs: 400,
  validators: {
    onDynamicAsync: async ({ value }: { value: string }) => {
      const available = await checkUsername(value);
      return available ? undefined : 'Username is already used';
    },
  },
}
```

Async work keeps the field (and default submit) busy. Stale results are dropped on reset / `clearValidation`.

## `formApi`

Methods that need the runtime wait until `<Form />` is mounted.

| Goal | Method |
| --- | --- |
| One field | `setFieldValue(name, value, shouldValidate?)` |
| Patch, keep dirty/errors | `setValues(patch, filterFields?, shouldValidate?)` |
| New record | `reset({ values })` or `reset()` |
| Load API-shaped values | `setSubmitValues(values)` (requires a codec) |
| Raw / encoded | `getRawValues()` / `getValues()` / `getValueSnapshot()` |
| Validate | `validate()`, `validateField(name)`, `isFieldValid(name)` |
| Server error | `setFieldError(name, message?)` |
| Clear | `clearValidation(names?)` |
| Submit | **`validateAndSubmit()`** (preferred) or `submit(event?)` |

`setValues` deep-merges objects, replaces arrays, and by default drops paths not in the schema. It is for patches in the **same** session, not for opening a different record in a reused drawer — use `reset({ values })` for that.

`validateAndSubmit()` validates once, returns `undefined` when invalid, otherwise calls `handleSubmit(values, rawValues)` and returns formatted values.

`updateSchema` / `setState({ schema })` change fields at runtime. `formApi.form` is the underlying TanStack Form instance for selectors (`useStore`) and low-level actions.

## Slots

| Slot | Use |
| --- | --- |
| `default` | Full custom layout (built-in actions are omitted) |
| `#<fieldName>` | Replace one field (`componentField`, `error`, `formApi`, `values`, …) |
| `submit-before` / `reset-before` | Extra action-area content |
| `expand-before` / `expand-after` | Collapse control |

## Codecs

```ts
const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
  decode(values) { /* API → UI */ },
  encode(values) { /* UI → API */ },
};
```

Exports: `calendarDateCodec`, `calendarDateTimeCodec`, `timeCodec`, `createCalendarDateCodec`, `createCalendarDateTimeCodec`, `createTimeCodec`. Failures throw `FormCodecError` with `phase: 'encode' | 'decode'`.

The web adapter default-assigns `calendarDateTimeCodec` when you omit `codec`.


## Sitemap

See the full [sitemap](https://vinicunca.dev/sitemap.md) for all pages.
