> ## Documentation Index
> Fetch the complete documentation index at: https://embeddables-platform.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Forms SDK

> @embeddables/forms — collect, validate, and persist form data across a funnel

## Overview

Forms manages form state for a funnel: values persist locally as a visitor progresses through it, input is validated against a schema, and completed data is saved to Embeddables in the background.

## Field types

A form is a list of fields, each with one of these types:

| Field type    | Description                         |
| ------------- | ----------------------------------- |
| `text`        | Free text                           |
| `email`       | An email address (format-validated) |
| `number`      | Numeric values                      |
| `boolean`     | True/false                          |
| `select`      | Single choice from a list           |
| `multiselect` | Multiple choices from a list        |
| `json`        | Structured data                     |

Fields support declarative validation: `required`, min/max length, min/max value, pattern matching, and custom validators.

## Implementation

### Install

<CodeGroup>
  ```bash npm theme={null}
  npm install @embeddables/forms @embeddables/core
  ```

  ```bash pnpm theme={null}
  pnpm add @embeddables/forms @embeddables/core
  ```

  ```bash yarn theme={null}
  yarn add @embeddables/forms @embeddables/core
  ```
</CodeGroup>

React apps also need `react` as a peer dependency. Install `@embeddables/analytics` only if you wire Analytics in the React example below.

### Define a schema

Each form is one object: an `id`, an optional `name`, and its `fields`.

```typescript theme={null}
import type { FormSchema } from '@embeddables/forms'

const schema = {
  id: 'signup',
  name: 'Signup',
  fields: [
    {
      key: 'email',
      label: 'Email',
      type: 'email',
      validations: { required: true },
    },
  ],
} as const satisfies FormSchema
```

Declarative validation rules: `required`, `minLength`, `maxLength`, `min`, `max`, `pattern` (a string, not a `RegExp`), `patternFlags`, `oneOf`, and an optional synchronous `validations.custom` function.

<Note>
  Use `as const satisfies FormSchema` on schemas you write by hand — it's what makes `.set()`,
  `.get()`, and `.getAll()` type-safe against your field keys. Schemas generated by the Embeddables
  CLI are already typed this way.
</Note>

### Quick start

```typescript theme={null}
import { initEmbeddables } from '@embeddables/core'
import { initForms } from '@embeddables/forms'

const core = initEmbeddables({
  projectId: '<your-project-id>',
  publishableKey: 'pk_sandbox_<your-key>',
  forms: [],
  experiments: [],
})

const { getForm } = initForms({ core, schemas: { signup: schema } })
const form = getForm({ formId: 'signup' })

const result = await form.set({ email: 'maria@gmail.com' })
if (!result.ok) console.log(result.errors)

const email = form.get('email')
await form.submit()
```

#### Form API

| Method                                       | Does                                                                                |
| -------------------------------------------- | ----------------------------------------------------------------------------------- |
| `set({ … })`                                 | Validates and saves a patch — all keys at once, or none                             |
| `get(key)` / `getAll()`                      | Read the currently stored values by field key                                       |
| `getValueByProtocolFieldId(protocolFieldId)` | Read a stored value by the field's `protocolFieldId`                                |
| `validate({ … })`                            | Check values without saving or tracking anything                                    |
| `submit()`                                   | Validates every field, saves, and (if Analytics is wired in) emits `form:submitted` |
| `errors()`                                   | Current validation messages                                                         |
| `clear()`                                    | Remove this form's stored values                                                    |

<Warning>
  `set`, `submit`, and `validate` never reject their promise on a validation failure — always check
  `result.ok` / `result.errors`. Durable saves to the backend are also best-effort: a failed save
  never rejects `.set()` or `.submit()` either.
</Warning>

<Tip>
  Bind `.set()` to `change`, `blur`, or a "next step" button — not to every keystroke. `.submit()`
  is not idempotent (it fires again on every call), so guard against double-clicks in your UI.
</Tip>

#### `initForms` / `getForm` options

| Option                             | Purpose                                                                                                                                            |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core`                             | Required. An initialized Core instance                                                                                                             |
| `schemas`                          | Required. Every form the app can open, keyed by form id — `embeddables/_dist` exports this as `forms`. Each schema's own `id` must equal its key   |
| `analyticsInstance`                | Optional — see [Analytics](#analytics) below                                                                                                       |
| `formId` (on `getForm`)            | Key of a schema declared on `initForms`; an unknown id throws `FormsError`                                                                         |
| `customValidations` (on `getForm`) | Per-field validator functions — use this instead of inline `validations.custom` for schemas that came from YAML/JSON (which can't carry functions) |

### React

Register Forms through `EmbeddablesProvider`'s `modules` prop — the CLI generates the list, including your form schemas, in `embeddables/_dist/modules` — then bind fields with `useForm` / `useFormField`.

```tsx theme={null}
import { EmbeddablesProvider } from '@embeddables/core/react'
import { useForm, useFormField } from '@embeddables/forms/react'
import { config } from './embeddables/_dist'
import { modules } from './embeddables/_dist/modules'

function SignupField() {
  // No type arguments: the id narrows the form; the schema map comes from the
  // registry `em build` writes into embeddables/_dist/register.ts.
  const { form } = useForm({ formId: 'signup' })
  const { value, error, setValue, onBlur } = useFormField({ form, key: 'email' })

  return (
    <label>
      Email
      <input
        value={value ?? ''}
        onChange={(event) => setValue(event.target.value)}
        onBlur={() => void onBlur()}
      />
      {error?.[0]}
    </label>
  )
}

export function App() {
  return (
    <EmbeddablesProvider config={config} modules={modules}>
      <SignupField />
    </EmbeddablesProvider>
  )
}
```

| Hook / factory                            | Does                                                                                                                                     |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `forms({ schemas, analyticsInstance? })`  | Module factory for the provider's `modules` prop; `schemas` is the `_dist` form map. `init(core)` registers one client per Core instance |
| `useForm({ formId, customValidations? })` | Reactive `{ form, values, errors }` for one declared form; caches one instance per form id                                               |
| `useFormField({ form, key })`             | Commit-on-blur binding: typing updates local draft state, `form.set()` runs on blur                                                      |
| `useFormErrors(form)`                     | Just the reactive validation errors                                                                                                      |

<Note>
  One live form instance is shared per `schema.id` across every hook call in your app — calling
  `useForm` with the same `formId` in two components does not create duplicate forms.
</Note>

### Analytics

Forms never imports Analytics. In non-React code, create an Analytics client and pass it as `analyticsInstance`:

```typescript theme={null}
import { initAnalytics } from '@embeddables/analytics'

const analytics = initAnalytics({ core })
const { getForm } = initForms({ core, schemas: { signup: schema }, analyticsInstance: analytics })
```

In React, the CLI orders modules `analytics → forms`, so Forms auto-wires `core.getAnalyticsInstance()` — you don't pass `analyticsInstance` yourself. Enable both modules in your `config.yaml` and the generated `modules` handles the rest.

When analytics is wired:

* A successful `.set()` emits `data:updated` plus one `field:updated` per changed field
* `.submit()` emits `form:submitted`

If tracking fails, it surfaces as `trackError` on the result — the values themselves are still saved either way. Omitting analytics only turns off event tracking; the best-effort durable backend save still happens whenever Core has a resolved publishable key.

<Warning>
  If analytics is wired, don't also call `analytics.trackEvent(...)` yourself for the same
  submission — you'll double-count `form:submitted`.
</Warning>

### Errors

| When                           | What you get                                         |
| ------------------------------ | ---------------------------------------------------- |
| Invalid Core instance at init  | `FormsError` thrown                                  |
| Invalid schema at init         | `SchemaError` thrown                                 |
| Validation failure / bad patch | `{ ok: false, errors }` on the result — never thrown |
| Analytics call fails           | `trackError` on the result — values are still saved  |

A custom validator that throws propagates synchronously out of `.set()` / `.submit()` — everything else is caught and reported on the result instead.
