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:
| Need | Recommended approach |
|---|---|
| Put a form on a page | Paste the basic embed only |
| Match the host application’s appearance | Use saved Appearance settings or Advanced Theming |
| React after an entry is saved or submitted | Listen for component events |
| Add host context such as a user ID or source page | Call setAnswers() with stable field IDs |
| Wait for autosave before closing a modal or changing route | Await flush() |
| Replace FormWork’s entire interface and navigation | Use 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
| Attribute | Required | Meaning |
|---|---|---|
form-id | Yes | Form to render |
hide-title | No | Set to true when the host already renders the form title |
hide-submit | No | Set to true when the host intentionally omits the built-in submit control |
theme | No | Set to auto, light, or dark to override the saved mode |
render-mode | No | Set 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
| Attribute | Use |
|---|---|
preview-version-id | Load a specific draft or published version for intentional preview use |
entry-id | Open a known existing entry; requires the matching entry-key |
entry-key | Authorise access to the entry identified by entry-id |
api-base-url | Override 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:
| Property | Meaning |
|---|---|
phase | loading, ready, submitting, submitted, or error |
schema | The form schema for this session |
entry | Current entry data; before persistence this can be a provisional object with an empty id, so check entry?.id |
answers | Effective answers, including pending local values |
serverAnswers | Latest answers confirmed by the server |
pendingAnswers | Local values not yet confirmed by the server |
validationErrors | Server-returned errors keyed by answer path |
fieldVisibility, pageVisibility | Server-calculated visibility keyed by field path or page ID |
dirty, saving, submitting | Useful host-interface status flags |
lastSavedAt | Client timestamp for the latest confirmed answer save, or null |
error | Latest 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:
| Property | Meaning |
|---|---|
code | create_failed, save_failed, or pending_answers_unsaved |
pendingAnswers | Values that could not be confirmed, for recovery or diagnostics |
cause | Original 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.
| Event | event.detail | When it fires |
|---|---|---|
formwork:ready | Session state | The 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-change | Session state | Any published state changes, including answers, validation, save flags, or phase |
formwork:saved | Session state | An 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
| Problem | Check |
|---|---|
| Nothing renders | Script present, valid form-id, no Content Security Policy block, and browser support for JavaScript modules and custom elements |
| A method is missing | Await customElements.whenDefined("formwork-form") and confirm the client script loaded |
getState() returns null | Wait for formwork:ready; the element is defined before its form session finishes loading |
| Draft appears in production | Remove preview-version-id and recopy the published snippet |
| Returning user starts over | Browser storage may be unavailable or cleared, or the draft may have expired, completed, or changed version |
| Existing entry is forbidden | entry-id and entry-key must match and remain valid |
| Host moves on before the latest typing is saved | Await flush() before the transition |
| Saved answers still have errors | Saving and validation are separate; inspect validationErrors and let the user correct the values |
| Host CSS cannot reach controls | Use theme variables or intentional render-mode="light" |