# Headless API and React



Everything the data attributes do, your code can do directly. Skip the `container` option and the SDK runs fully headless; keep it and mix both styles.

## The form handle [#the-form-handle]

`SurfaceForms.init` resolves to a `SurfaceForm` handle:

| Member                            | What it does                                                                                                             |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `setAnswer(questionId, state)`    | Merges a partial state into the question's answer. Use the exact shape from the binding map.                             |
| `next()` / `back()`               | Linear advance and rewind. `next()` resolves on the step change; `back()` never writes.                                  |
| `goToStep(stepId)`                | Your routing. Forward jumps save; jumps to a visited step rewind. Rejects unknown IDs.                                   |
| `submit()` / `disqualify()`       | Finish the form; the terminal write is awaited, then the matching ending step is revealed.                               |
| `savePartial()`                   | Explicit mid-form save (fire-and-forget beacon).                                                                         |
| `flush()`                         | Resolves when every queued write has settled. Await before programmatic teardown.                                        |
| `state()`                         | `{ currentStepId, answers, externalStates, responseId, resumeToken, finished }`. Referentially stable between mutations. |
| `subscribe(cb)` / `on(event, cb)` | Store subscription and lifecycle events; both return an unsubscribe function.                                            |
| `mergeMeta(partial)`              | Record caller-computed values under `meta.custom` (see below).                                                           |
| `identify()`                      | Re-run visitor identification after an SPA route change.                                                                 |
| `validateEmail(email)`            | On-demand deliverability verdict. See [Email validation](/docs/forms-sdk/email-validation).                              |
| `capabilities`                    | `{ enrichment, emailValidation, tracking }`: what actually runs in this environment.                                     |
| `runtimeConfig`                   | The fetched form record.                                                                                                 |
| `markViewed()`                    | Fire the viewed events once. The binding layer does this via IntersectionObserver; headless callers do it themselves.    |
| `destroy()`                       | Tear down every binding. One engine per page life; re-running `init` without `destroy` creates duplicate responses.      |

### Events [#events]

```js
form.on("saved", ({ responseId, resumeToken, finished }) => { /* persist for resume */ });
```

Events: `viewed`, `started`, `stepChanged` (`{ fromStepId, toStepId }`), `stepCompleted`, `completed`, `disqualified`, `saved`, `error` (`{ scope, error }`), `scheduler` (see [Schedulers](/docs/forms-sdk/schedulers)).

### Reading answers [#reading-answers]

`state().answers` is keyed by step ID, then question ID:

```js
const email = form.state().answers["step_about"]?.["q_email"]?.email;
```

`setAnswer` records under the current step, so set each step's answers before `next()` or `goToStep()`. Every answer rides every save regardless; a late `setAnswer` only lands under the wrong step for per-step reads and analytics (`debug: true` warns).

### Typed answer states [#typed-answer-states]

One exported type per component (`ShortInputAnswer`, `DropdownAnswer`, `MultipleOptionsAnswer`, `IdentityInfoAnswer`, `SchedulerAnswer`, ...) plus the union `KnownAnswerState`:

```ts
import type { MultipleOptionsAnswer } from "@surface-labs/forms-sdk";

const answer: MultipleOptionsAnswer = {
  type: "MultipleOptionsQuestion",
  choices: [
    { key: "Sales Ops", value: true, index: 0 },
    { key: "Leadership", value: false, index: 1 },
  ],
};
form.setAnswer("q_role", answer);
```

Choice answers are structured lists, not label strings. Copy each question's `answerShape` from the binding map; the wrong shape is the classic production 400.

### Custom values on the response [#custom-values-on-the-response]

`mergeMeta(partial)` records caller-computed values (a score, an experiment bucket) under `meta.custom`, riding the next save or amending a finished response. Only that namespace persists; visitor input belongs in answers.

## React [#react]

Two patterns, depending on who renders the inputs.

### Fully headless (controlled inputs) [#fully-headless-controlled-inputs]

`init` without `container`, render everything from the hook:

```tsx
import { SurfaceForms, type SurfaceForm } from "@surface-labs/forms-sdk";
import { useSurfaceForm } from "@surface-labs/forms-sdk/react";
import { useEffect, useState } from "react";

export function QualifyForm() {
  const [form, setForm] = useState<SurfaceForm | null>(null);

  useEffect(() => {
    let handle: SurfaceForm | null = null;
    let disposed = false;
    void SurfaceForms.init({
      environmentId: "env_xxx",
      formId: "form_xxx",
      apiBaseUrl: "https://forms.withsurface.com",
    }).then((created) => {
      if (disposed) created.destroy();
      else {
        handle = created;
        setForm(created);
      }
    });
    return () => {
      disposed = true;
      handle?.destroy();
    };
  }, []);

  if (!form) return null;
  return <QualifySteps form={form} />;
}

function QualifySteps({ form }: { form: SurfaceForm }) {
  const { state, setAnswer, goToStep, submit } = useSurfaceForm(form);
  // render inputs from state, setAnswer onChange, navigate from your handlers
}
```

`useSurfaceForm` is a `useSyncExternalStore` adapter: `state` re-renders on engine mutations, stable between them. React 18+ is an optional peer dependency; the main entry stays React-free.

### Data-attribute binding inside React [#data-attribute-binding-inside-react]

React renders the markup, the SDK captures answers. Two ownership rules:

1. **`hidden` belongs to the SDK.** Emit it as a constant in the initial JSX for every step after the first; a `hidden={step !== current}` expression re-hides the step the SDK just revealed.
2. **Keep bound inputs uncontrolled and mounted.** Listeners attach at `init`; a remounted input has no listener, and a controlled `value` fights the visitor's typing. Read answers from `form.state().answers`, not React state.

In the effect cleanup, call `destroy()` alone; beacons survive unmount and the finishing write was already awaited by `submit()`. On SPA route changes, call `form.identify()`.

## Running in Node [#running-in-node]

Node 18+, native `fetch`, no DOM shim; the standard way to verify a record without a browser:

```js
import { SurfaceForms } from "@surface-labs/forms-sdk";

const form = await SurfaceForms.init({ environmentId, formId, apiBaseUrl, journey: false });
form.setAnswer("q_email", { type: "ShortInput", input: "qa@example.com" });
await form.submit(); // resolves once the server has the finished response
console.log(form.state().responseId);
```

No group auto-build without the DOM, so set choice questions' structured shapes yourself. SDK warnings go to stderr (`console.warn`); capture `2>&1` before grepping.
