Forms
Record forms and multi-step intake forms in Standards.
Forms has two meanings in Standards, and they solve different problems.
Detail view form tabs edit one object record. They live inside detailView() definitions and organize attributes into groups on a record page.
form() definitions create multi-step submissions. They can collect values for one or more object slots, include free fields that are not object attributes, and run as a guided intake flow.
Detail view forms
Use a detail view form tab when the user is editing the current record:
// @noverify
import { detailView, group } from "@stndrds/schema";
detailView("detail", "Contact")
.for("contacts")
.tab("general", "General")
.form(
group("identity", "Identity")
.field("firstName", { span: 6 })
.field("lastName", { span: 6 })
.field("email", { span: 12 })
)
.build();This does not create a submission. It is part of the record edit UI for one object.
Multi-step forms
Use form() when the user is starting or continuing a guided process. Slots name the records the submission will create, select, or optionally bind.
// @noverify
import { form, formRegistry, text } from "@stndrds/schema";
export const CLIENT_ONBOARDING = form("client-onboarding", "Client onboarding")
.description("Collect a contact and link it to a company.")
.status("published")
.slot("contact", "contacts", { label: "Contact", mode: "create", icon: "user" })
.slot("company", "companies", { label: "Company", mode: "select", icon: "building" })
.step("identity", "Identity")
.row("name")
.field("contact", "firstName", { label: "First name", required: true })
.field("contact", "lastName", { label: "Last name", required: true })
.endRow()
.freeField(text({ name: "notes", label: "Notes" }), {
prefillExpression: "{{ contact.firstName }} {{ contact.lastName }}",
})
.build();
formRegistry.register(CLIENT_ONBOARDING);Register forms from your schema entry point and enable form sync in the backend with sync: { forms: true }.
Slot modes
Each slot has a mode:
createcreates a new record for that slot.selectrequires the submission to bind an existing record.optionalallows the slot to be omitted.create_if_not_emptycreates the record only when the user provides values for that slot.
Choose slots for records that should survive the submission. Use free fields for transient answers, notes, or routing inputs that should stay on the form submission rather than becoming attributes on an object.
Frontend usage
FormFlow is exported by @stndrds/ui and renders the form UI. It requires definition, submission, and objects props:
// @noverify
import { FormFlow } from "@stndrds/ui";
<FormFlow definition={form} submission={submission} objects={objects} />;The React hooks drive submission state: create a submission, submit field values, and advance between steps. FormFlow calls the fill and navigation hooks internally, but you can also use them directly when building custom controls.
// @noverify
"use client";
import { useCreateFormSubmission, useFormByName } from "@stndrds/react";
export function StartClientOnboarding() {
const { data: form } = useFormByName("client-onboarding");
const createSubmission = useCreateFormSubmission();
if (!form?.id) return null;
return (
<button
onClick={() =>
createSubmission.mutate({
formId: form.id,
slotValues: { contact: { firstName: "Ada" } },
})
}
>
Start onboarding
</button>
);
}The example starts a submission with an initial contact value. A full screen usually fetches the form and submission, renders FormFlow, and calls the hooks when the user saves or moves to the next step.
For custom step controls, use the step and submit hooks directly:
// @noverify
import { useAdvanceFormStep, useSubmitFormFill } from "@stndrds/react";
const advance = useAdvanceFormStep();
const submit = useSubmitFormFill();
advance.mutate({ formId, submissionId, values });
submit.mutate({ formId, submissionId });values is the current step payload.