FormWork documentation

Web Component & JavaScript API

Embed a FormWork form, then add host data, custom controls, and lifecycle integrations when you need them.

The <formwork-form> web component is the supported way to run a FormWork form in plain HTML or a browser framework. It loads the published form, renders the fields, autosaves answers, applies server-side display and validation rules, and submits the entry.

Most sites only need the embed code from Share. The JavaScript API is for applications that also need to coordinate the form with their own interface or data.

Is the JavaScript API for you?

Start with the smallest integration that meets your needs:

NeedRecommended approach
Put a form on a pagePaste the basic embed only
Match the host application’s appearanceUse saved Appearance settings or Advanced Theming
React after an entry is saved or submittedListen for component events
Add host context such as a user ID or source pageCall setAnswers() with stable field IDs
Wait for autosave before closing a modal or changing routeAwait flush()
Replace FormWork’s entire interface and navigationUse the public REST API rather than the web component

The component API lets the host observe and coordinate the standard FormWork experience. Its attributes, methods, and events are the public browser contract; there is not currently a separately supported npm package. Reaching into the shadow DOM or importing FormWork’s internal renderer is not a supported integration.

Basic embed

Copy the production snippet from the form’s Share page:

<formwork-form form-id="FORM_ID"></formwork-form>
<script src="https://app.useformwork.com/static/client.js" type="module"></script>

One script can define any number of <formwork-form> elements on the page. Published embeds automatically resolve the latest published version; an entry that has already started remains on the version it began with.

For direct links and draft snippets, see Sharing & Preview.

How the form session works

Loading the form creates a browser session, but it does not immediately create an entry. FormWork creates the entry lazily when the respondent first performs an operation that must be saved. This avoids empty entries from visitors who never interact with the form.

The component then owns the entry session:

  • Answers are autosaved, with short delays for text entry.
  • Answer mutations are applied in order, even if the respondent changes several fields quickly.
  • The server remains authoritative for validation, visibility, resolved content, and form version data.
  • An incomplete entry can be resumed automatically in the same browser tab.
  • A successful submission clears the stored resume access.

This is why formwork:ready can fire before state.entry?.id contains a persisted entry ID, and why host actions should use flush() instead of guessing whether an autosave timer is still running.

Common attributes

AttributeRequiredMeaning
form-idYesForm to render
hide-titleNoSet to true when the host already renders the form title
hide-submitNoSet to true when the host intentionally omits the built-in submit control
themeNoSet to auto, light, or dark to override the saved mode
render-modeNoSet to light to use light DOM; the default is shadow DOM

With the exception of theme, attributes are read when the element connects. To change the form, entry, version, or render mode, replace or reconnect the element instead of changing an attribute in place.

Preview and existing-entry attributes

AttributeUse
preview-version-idLoad a specific draft or published version for intentional preview use
entry-idOpen a known existing entry; requires the matching entry-key
entry-keyAuthorise access to the entry identified by entry-id
api-base-urlOverride the FormWork origin in a controlled non-production environment; omit it for normal production embeds

Do not put preview-version-id on a production embed. An application that securely holds entry credentials can resume that entry explicitly:

<formwork-form
  form-id="FORM_ID"
  entry-id="ENTRY_ID"
  entry-key="ENTRY_ACCESS_KEY"
></formwork-form>

The entry uses the form version it was created with. Treat entry-key as a bearer credential: never hard-code it in generally visible HTML or send it to analytics and logs.

JavaScript API at a glance

The custom element exposes four methods:

const form = document.querySelector("formwork-form");

const answers = form.getAnswers();
const state = form.getState();
const savedAnswers = await form.setAnswers({
  external_reference: "customer_123",
});
const persistedAnswers = await form.flush();

Use the API only after the component has created its session. Listening before the external module loads is safe because events use the normal DOM event system:

<formwork-form id="contact-form" form-id="FORM_ID"></formwork-form>

<script type="module">
  const form = document.querySelector("#contact-form");

  form.addEventListener(
    "formwork:ready",
    (event) => {
      console.log("Form ready", event.detail);
    },
    { once: true },
  );
</script>

<script src="https://app.useformwork.com/static/client.js" type="module"></script>

If code might run after the ready event, wait for the element definition and inspect getState() first:

await customElements.whenDefined("formwork-form");
const form = document.querySelector("formwork-form");

if (!form.getState()) {
  await new Promise((resolve) => {
    form.addEventListener("formwork:ready", resolve, { once: true });
  });
}

Common integration recipes

Add host data to a new entry

Use formwork:entry-created when metadata should be attached only to a newly created entry, not to a resumed draft:

form.addEventListener(
  "formwork:entry-created",
  async () => {
    try {
      await form.setAnswers({
        reporter_email: currentUser.email,
        source_page: window.location.href,
      });
    } catch (error) {
      console.error("Could not attach form context", error);
    }
  },
  { once: true },
);

Listen to both formwork:entry-created and formwork:entry-resumed only when the same check or update should run for existing drafts too.

The target fields can be read-only if respondents should see but not change the values. Use an entry metafield instead when the data is administrative and should not be part of the respondent-facing answer set.

Finish a host-controlled transition safely

When a host button saves its own screen, redirects, closes a modal, or starts an admin action, wait until FormWork has persisted the latest typing:

async function continueHostFlow() {
  try {
    const answers = await form.flush();
    await saveHostApplication(answers);
  } catch (error) {
    showRetryMessage("The latest form changes could not be saved.");
  }
}

Keep the host action available when flush() fails so the user can retry. Do not treat a successful flush as a submission or as proof that the form is valid.

Observe successful submission

form.addEventListener("formwork:submitted", (event) => {
  analytics.track("Form submitted", {
    entryId: event.detail.entry.id,
  });
});

Use the completed-action event rather than watching clicks on the submit button. A click can still be stopped by validation or another client-side step.

Answer paths

Answers are a flat map keyed by stable field IDs, not a nested JavaScript object. Groups use dots and repeatable instances include their server-created instance ID:

{
  "email": "[email protected]",
  "address.city": "Leeds",
  "line_items[item_a].quantity": 2
}

Use the IDs defined in the form builder, not field labels. Labels can change; IDs cannot. See Form Structure for groups and repeatable paths.

setAnswers() can update an existing repeatable instance when its path is already present in getAnswers(). The web component API does not expose operations for creating or removing repeater instances; leave those interactions to the rendered form.

Method reference

getAnswers()

Returns the current effective answer map. It includes local changes that may still be waiting for autosave, so it is suitable for updating the host interface but is not proof of persistence.

Before the session is ready it returns an empty object.

getState()

Returns the current immutable session state, or null before the session is ready. Treat the object as read-only and allow new properties to be added in future.

The most useful properties are:

PropertyMeaning
phaseloading, ready, submitting, submitted, or error
schemaThe form schema for this session
entryCurrent entry data; before persistence this can be a provisional object with an empty id, so check entry?.id
answersEffective answers, including pending local values
serverAnswersLatest answers confirmed by the server
pendingAnswersLocal values not yet confirmed by the server
validationErrorsServer-returned errors keyed by answer path
fieldVisibility, pageVisibilityServer-calculated visibility keyed by field path or page ID
dirty, saving, submittingUseful host-interface status flags
lastSavedAtClient timestamp for the latest confirmed answer save, or null
errorLatest client error, or null

While answers are pending, serverStateStale, visibilityStale, and validationStale indicate that server-derived state may still describe the previous confirmed answers. Prefer the component’s rendered state until reconciliation completes.

setAnswers(answers)

Immediately saves the supplied path-addressed values. It creates an entry if necessary and resolves with the complete current answer map after the server confirms the save.

const answers = await form.setAnswers({
  external_reference: "customer_123",
  "contact.email": "[email protected]",
});

This method is intended for trusted host context and explicit host controls. It is not a way to bypass FormWork logic: the server still recalculates defaults, display logic, resolved content, and validation. Inspect getState().validationErrors if the host needs to respond to the resulting validation state.

setAnswers() rejects if the session is not ready or the save cannot be confirmed.

flush()

Persists pending answer changes and waits for scheduled and in-flight saves to settle. It resolves with the current answers when persistence is confirmed.

flush() does not submit the entry and does not guarantee that the answers are valid. Validation errors returned by a successful save are normal persisted state.

If persistence cannot be confirmed, it rejects with a FormClientFlushError. The error has:

PropertyMeaning
codecreate_failed, save_failed, or pending_answers_unsaved
pendingAnswersValues that could not be confirmed, for recovery or diagnostics
causeOriginal error when one is available

Do not call flush() before the session is ready; wait for formwork:ready or a non-null getState() first.

Event reference

Every FormWork event bubbles and crosses the component’s shadow DOM boundary, so a host can listen on the element or an ancestor.

Register entry lifecycle listeners before loading the client script or immediately when inserting the element. An automatically resumed entry can be reported during initialisation, before formwork:ready.

Eventevent.detailWhen it fires
formwork:readySession stateThe session API is ready; a persisted entry might not exist yet
formwork:entry-created{ entry, state }A new entry has been persisted
formwork:entry-resumed{ entry, state }An existing entry was loaded from stored resume access or explicit credentials
formwork:state-changeSession stateAny published state changes, including answers, validation, save flags, or phase
formwork:savedSession stateAn answer save was confirmed by the server
formwork:submitted{ entry }The entry was submitted successfully
formwork:error{ message, error }Initialisation, persistence, submission, or extension work failed

formwork:state-change can fire frequently. Filter the state you care about and debounce any analytics or network request made from this listener. Prefer the more specific lifecycle events when they express the result you need.

The current completed-action event names are formwork:state-change, formwork:saved, and formwork:submitted. Older examples using formwork:change, formwork:save, or formwork:submit do not match the current client API.

Custom controls and submission

Use hide-title="true" when the host already supplies the same heading. Use hide-submit="true" only when the host deliberately does not want FormWork’s terminal submit action.

The web component does not expose a public submit() method. A host can flush and continue a different workflow, but successful FormWork submission remains driven by the built-in client interaction. If you need to implement form navigation, repeaters, uploads, and submission yourself, use the REST API.

Resume and credential safety

Automatic resume is enabled by default. Returning to the same form in the same browser-tab session resumes an incomplete draft when possible. An expired or completed draft starts a fresh form.

FormWork resume links are also handled automatically. A resume link or entry-key provides access to an incomplete entry, so avoid copying it into logs, analytics, support screenshots, or public HTML. Never expose an account API key in browser code.

Rendering and styling

Default shadow DOM protects the form from host CSS. CSS variables set on <formwork-form> still cross that boundary. render-mode="light" places the rendered form in the page’s light DOM, which allows broader host styling but increases the risk of CSS collisions.

Use Appearance for saved no-code styling and Advanced Theming for CSS variables, mode precedence, and light DOM guidance.

Troubleshooting

ProblemCheck
Nothing rendersScript present, valid form-id, no Content Security Policy block, and browser support for JavaScript modules and custom elements
A method is missingAwait customElements.whenDefined("formwork-form") and confirm the client script loaded
getState() returns nullWait for formwork:ready; the element is defined before its form session finishes loading
Draft appears in productionRemove preview-version-id and recopy the published snippet
Returning user starts overBrowser storage may be unavailable or cleared, or the draft may have expired, completed, or changed version
Existing entry is forbiddenentry-id and entry-key must match and remain valid
Host moves on before the latest typing is savedAwait flush() before the transition
Saved answers still have errorsSaving and validation are separate; inspect validationErrors and let the user correct the values
Host CSS cannot reach controlsUse theme variables or intentional render-mode="light"