Headless API and React
Drive the form engine from code: the form handle, typed answers, events, and the React adapter
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
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. |
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
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).
Reading answers
state().answers is keyed by step ID, then question ID:
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
One exported type per component (ShortInputAnswer, DropdownAnswer, MultipleOptionsAnswer, IdentityInfoAnswer, SchedulerAnswer, ...) plus the union KnownAnswerState:
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
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
Two patterns, depending on who renders the inputs.
Fully headless (controlled inputs)
init without container, render everything from the hook:
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
React renders the markup, the SDK captures answers. Two ownership rules:
hiddenbelongs to the SDK. Emit it as a constant in the initial JSX for every step after the first; ahidden={step !== current}expression re-hides the step the SDK just revealed.- Keep bound inputs uncontrolled and mounted. Listeners attach at
init; a remounted input has no listener, and a controlledvaluefights the visitor's typing. Read answers fromform.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
Node 18+, native fetch, no DOM shim; the standard way to verify a record without a browser:
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.