Production recipes
Use this page after the first generated form works. Choose the recipe that matches the problem in your application.
| Problem | Use |
|---|---|
| Add product UI around generated fields | One binding with kit.Form and kit.Fields |
| Replace the document being edited | A React key or form.api.reset(nextInput) |
| Discard edits | A type="reset" button |
| Save a new baseline without replacing later edits | form.api.resetDefaultValues(input) |
| Keep dependent values atomic | middleware and form.update(recipe) |
| Apply a raw RHF update to multiple fields | form.api.setValues(values) |
| Observe values without a form render subscription | form.api.subscribe |
| Validate one wizard step | form.api.trigger and form.api.setFocus |
| Build a complete product workflow | useFormWorkflow as application code |
| Save parsed data | The value argument in onSubmit |
| Show a request failure | Application state with role="alert" |
| Show a server field issue | form.api.setError |
| Load one remote option list | Async field options |
| Coordinate request state with form UI | Form context and fromResource |
| Lock the complete form | The disabled or readOnly option |
| Add a design-system control | defineControl and the full ControlProps contract |
React Hook Form owns editable form state. The application owns requests, caches, persistence, navigation, permissions, and product workflow state.
Compose generated and custom UI
Create one form binding for one form. Put generated fields, application
actions, RHF fields, and watched state inside the same kit.Form.
function ProfileForm() {
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: emptyProfile,
})
const email = useWatch({ control: form.api.control, name: "email" })
const state = useFormState({ control: form.api.control })
let dirtyState = <output>No changes</output>
if (state.isDirty) dirtyState = <output>Unsaved changes</output>
return (
<nativeFormKit.Form form={form}>
<nativeFormKit.Fields />
<output>Current email: {email}</output>
<button
type="button"
onClick={() => form.api.setValue("department", "Research")}
>
Use Research department
</button>
{dirtyState}
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.Form>
)
}Use RHF Controller, useController, or register for custom fields. Use
useWatch and useFormState for derived UI. The unchanged methods on
form.api, such as setValue, remain available for application actions.
Do not copy a form value into React state. Two editable copies can become inconsistent. Do not create a second form binding for another panel that edits the same document.
Use kit.AutoForm when the error summary and generated fields can render first.
It renders custom children after the generated fields. Use kit.Form with
kit.Fields when custom content needs another position or no error summary.
Load an edit-form baseline
Wait for the document before you mount the component that calls kit.useForm.
Use the document ID as a React key when navigation can replace the document.
function ProfileScreen({ profile }: { readonly profile: Profile | undefined }) {
if (profile === undefined) return <p>Loading…</p>
return <ProfileEditor key={profile.id} profile={profile} />
}
function ProfileEditor({ profile }: { readonly profile: Profile }) {
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: profile,
})
return (
<nativeFormKit.AutoForm form={form}>
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.AutoForm>
)
}The form definition and synchronous defaultValues are fixed for one component
mount. Changing either prop does not replace current values.
Do not use this conditional update as a document navigation workflow. Remount
the editor for a different document. Call form.api.reset(nextInput) only when
the application intentionally replaces the current work.
Reset or discard changes
A reset without arguments restores the current baseline. A reset with a value installs that schema input as the next baseline.
function ResettableProfile({ profile }: { readonly profile: Profile }) {
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: profile,
onSubmit: async ({ value, form }) => {
const saved = await updateProfile(value)
form.api.reset(saved)
},
})
const state = useFormState({ control: form.api.control })
return (
<nativeFormKit.AutoForm form={form}>
<button disabled={!state.isDirty} type="reset">
Discard changes
</button>
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.AutoForm>
)
}kit.Form handles the native reset event. It prevents the browser from
resetting controls independently and calls form.api.reset().
Use these operations for different results:
| Operation | Result |
|---|---|
form.api.reset() | Restore the current baseline and clear form metadata. |
form.api.reset(nextInput) | Install nextInput as the clean baseline. |
Change defaultValues | No effect until the hook remounts. |
Change the editor key | Create a new form for another document. |
Update the saved baseline without replacing new edits
Requires React Hook Form 7.77.0 or newer.
Use resetDefaultValues after the server saves the submitted input. This
operation changes the clean baseline. It does not replace the current field
values.
export function SavedBaselineRecipe() {
const [savedName, setSavedName] = useState<string>()
const isClientReady = useClientReady()
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: recipeProfile,
onSubmit: async ({ input, value, form }) => {
await saveProfileSnapshot(value)
form.api.resetDefaultValues(input)
setSavedName(value.name)
},
})
const state = useFormState({ control: form.api.control })
let status = "No unsaved changes"
if (state.isDirty) status = "Unsaved changes"
if (state.isSubmitting) status = "Saving. You can continue to edit."
return (
<section
aria-label="Saved baseline recipe preview"
className="form-please-complex"
data-demo-client-ready={isClientReady}
>
<p className="form-please-complex__kicker">Live preview</p>
<p className="form-please-complex__summary">
Submit the form. Change the name before the save operation finishes.
</p>
<nativeFormKit.AutoForm form={form}>
<div className="form-please-complex__actions">
<nativeFormKit.Submit className="form-please-complex__primary">
Save current values
</nativeFormKit.Submit>
<output aria-live="polite">{status}</output>
</div>
</nativeFormKit.AutoForm>
{savedName !== undefined && (
<output aria-live="polite">Saved baseline: {savedName}</output>
)}
</section>
)
}The preview waits before it completes the save operation. Submit the form, then change the name. The new name stays in the field and the form stays dirty.
Live preview
Submit the form. Change the name before the save operation finishes.
Use the submitted input when the server accepts that exact input. Use the
server's canonical schema input when the server returns different editable
values.
Keep dependent values atomic
Configure value middleware when one generated change must update dependent
fields before observers see the result. Call form.update when an application
action needs the same managed boundary.
For one form-local rule, use beforeUpdate to adjust or cancel the proposal and
afterUpdate to observe its final committed transaction. Use middleware when
independent policies must compose in an explicit order.
function ProfileUpdateHooks({ context }: { readonly context: ProfileContext }) {
const form = profileKit.useForm(profileDefinition, {
beforeUpdate(draft, transaction) {
if (
!context.canEditPlan &&
transaction.source.type === "control" &&
transaction.source.path === "plan"
) {
return false
}
if (draft.plan === "solo") draft.teamName = undefined
},
afterUpdate(transaction) {
recordManagedValues(transaction.nextValues)
},
context,
defaultValues,
})
return <profileKit.AutoForm form={form} />
}See Value middleware for complete setup, live previews, cancellation, ordering, array constraints, and validation timing.
function recordManagedValues(_values: DeepReadonly<ProfileInput>) {}
const keepPlanValuesConsistent: FormMiddleware<ProfileInput, ProfileContext> =
(api) => (next) => (transaction) => {
let patches = transaction.patches
if (
transaction.nextValues.plan === "solo" &&
transaction.nextValues.teamName !== undefined
) {
patches = [
...transaction.patches,
{ op: "replace", path: ["teamName"], value: undefined },
]
}
const result = next(patches)
// `next` commits synchronously, so this reads the complete final value.
recordManagedValues(api.getValues())
return result
}Middleware receives Immer patches and a nextValues value derived from them.
Forward the original patches, append dependent patches, or return without
next to cancel the complete proposal. Code after synchronous next reads the
committed final value.
Generated controls and form.update make one RHF value publication. Generated
array actions retain native useFieldArray row identity and promise one final
React render, not one raw RHF publication. Direct form.api operations bypass
middleware, so keep invariant-preserving application actions on form.update.
Only the proposed generated array action can change generated array length or
order. Do not change another generated array's structure in the same
transaction. Use that array's generated action to keep row IDs synchronized.
Use raw useFieldArray methods only when bypassing middleware is intentional.
Do not call next after await, call it twice, or start another managed update
inside an active transaction. Use an RHF subscription when you only need to
observe values; subscriptions run after commit and cannot make dependent
changes atomic.
Apply one update to multiple fields
Form, Please requires React Hook Form 7.76.1 or newer.
Use raw setValues when one application action changes multiple fields but
must deliberately bypass Form, Please middleware. One call updates the fields
and applies the same dirty, touch, and validation options to each value.
export function AtomicValuesRecipe() {
const isClientReady = useClientReady()
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: recipeProfile,
})
const [templateApplied, setTemplateApplied] = useState(false)
let status = "No template applied"
if (templateApplied) status = "Profile template applied."
return (
<section
aria-label="Atomic values recipe preview"
className="form-please-complex"
data-demo-client-ready={isClientReady}
>
<p className="form-please-complex__kicker">Live preview</p>
<p className="form-please-complex__summary">
Apply one template to multiple fields.
</p>
<nativeFormKit.AutoForm form={form}>
<div className="form-please-complex__actions">
<button
type="button"
onClick={() => {
form.api.setValues(
{
name: "Grace Hopper",
email: "grace@example.com",
department: "Compilers",
},
{ shouldDirty: true, shouldValidate: true },
)
setTemplateApplied(true)
}}
>
Apply profile template
</button>
<output aria-live="polite">{status}</output>
</div>
</nativeFormKit.AutoForm>
</section>
)
}The preview applies one profile template to three fields.
Live preview
Apply one template to multiple fields.
Pass only the fields that the action owns. Do not use setValues to install a
new document baseline. Use reset(nextInput) for that operation. Use
form.update instead when application middleware must enforce invariants.
Save a draft with a subscription
Requires React Hook Form 7.76.1 or newer, which is the package peer minimum.
Use subscribe when an external process needs current form values. The
subscription does not rerender the form when values change. This preview
updates application state only to show the saved draft.
export function DraftSubscriptionRecipe() {
const [savedDraft, setSavedDraft] = useState<Profile>()
const isClientReady = useClientReady()
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: recipeProfile,
})
const api = form.api
useEffect(() => {
let saveTimer: ReturnType<typeof setTimeout> | undefined
const unsubscribe = api.subscribe({
formState: { values: true },
callback: ({ values }) => {
if (saveTimer !== undefined) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
// Replace this state update with your draft storage call.
setSavedDraft(values)
}, 400)
},
})
return () => {
if (saveTimer !== undefined) clearTimeout(saveTimer)
unsubscribe()
}
}, [api])
let status = "Edit a field to save a draft."
if (savedDraft !== undefined) {
let draftName = savedDraft.name
if (draftName === "") draftName = "the unnamed profile"
status = `Draft saved for ${draftName}.`
}
return (
<section
aria-label="Draft subscription recipe preview"
className="form-please-complex"
data-demo-client-ready={isClientReady}
>
<p className="form-please-complex__kicker">Live preview</p>
<p className="form-please-complex__summary">
Edit a field. The preview saves the draft after 400 ms.
</p>
<nativeFormKit.AutoForm form={form} />
<output aria-live="polite">{status}</output>
</section>
)
}The preview waits 400 ms after the last change, then stores the current draft.
Live preview
Edit a field. The preview saves the draft after 400 ms.
Return the unsubscribe function from the effect cleanup. Do not call form mutation methods from the subscription callback.
Validate one wizard step
Requires React Hook Form 7.76.1 or newer, which is the package peer minimum.
Use trigger with the paths for the current step. Use setFocus when the step
contains an invalid field. Hidden fields keep their values when the next step
renders.
export function StepValidationRecipe() {
const [step, setStep] = useState<WizardStep>("identity")
const [saved, setSaved] = useState(false)
const isClientReady = useClientReady()
const context = useMemo(() => ({ step }), [step])
const form = wizardKit.useForm(wizardDefinition, {
context,
defaultValues: { name: "", email: "", department: "" },
onSubmit: () => setSaved(true),
})
async function showDetails() {
for (const path of identityFields) {
form.api.setValue(path, form.api.getValues(path), { shouldTouch: true })
}
const valid = await form.api.trigger(identityFields)
if (!valid) {
const firstInvalid = identityFields.find(
(path) => form.api.getFieldState(path).invalid,
)
if (firstInvalid !== undefined) form.api.setFocus(firstInvalid)
return
}
setStep("details")
}
let stepLabel = "1 of 2: identity"
let actions = (
<button type="button" onClick={() => void showDetails()}>
Continue
</button>
)
if (step === "details") {
stepLabel = "2 of 2: details"
actions = (
<>
<button type="button" onClick={() => setStep("identity")}>
Back
</button>
<wizardKit.Submit className="form-please-complex__primary">
Save profile
</wizardKit.Submit>
</>
)
}
let status = "Complete the current step."
if (saved) status = "Profile saved."
return (
<section
aria-label="Step validation recipe preview"
className="form-please-complex"
data-demo-client-ready={isClientReady}
>
<p className="form-please-complex__kicker">Live preview</p>
<p className="form-please-complex__summary">Step {stepLabel}</p>
<wizardKit.AutoForm form={form}>
<div className="form-please-complex__actions">{actions}</div>
</wizardKit.AutoForm>
<output aria-live="polite">{status}</output>
</section>
)
}The preview marks the current fields as touched before validation. This action makes their validation messages visible without a form submission.
Live preview
Step 1 of 2: identity
Keep the complete form rules in the Standard Schema. The path list controls step navigation. It does not create a second validation contract.
For typed paths, conditional screens, progress, review, and first-invalid navigation, follow the complete Product workflows tutorial.
Keep async submission in the application
Return the request promise from onSubmit. React Hook Form keeps
isSubmitting true until that promise settles. kit.Submit disables its button
while validation or submission is in progress.
function SavingProfile({ profile }: { readonly profile: Profile }) {
const [requestError, setRequestError] = useState<string>()
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: profile,
onSubmit: async ({ value, form }) => {
setRequestError(undefined)
try {
const saved = await updateProfile(value)
form.api.reset(saved)
} catch (error) {
setRequestError(getRequestErrorMessage(error))
}
},
})
const state = useFormState({ control: form.api.control })
let submitState = <output aria-live="polite">Ready</output>
if (state.isSubmitting) {
submitState = <output aria-live="polite">Saving…</output>
}
return (
<nativeFormKit.AutoForm form={form}>
{requestError !== undefined && <p role="alert">{requestError}</p>}
{submitState}
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.AutoForm>
)
}Clear an old request error before each attempt. Render the new failure from application state. Reset the form only after the server returns a successful, canonical input.
A transport failure is not a schema issue. Keep it out of the form error
summary. If the server identifies invalid field values, adapt that response to
the schema or RHF setError.
The visible kit.Submit prevents normal duplicate clicks. If application code
calls form.api.handleSubmit(onValid, onInvalid), that code must also prevent
concurrent calls. Raw handleSubmit does not invoke the configured
"Form, Please" wrapper.
Apply server field errors
Use form.api.setError when the server identifies a specific editable field.
Keep request and form-level failures in application state.
First, define and validate the server response:
const serverProfileResultSchema = z.discriminatedUnion("ok", [
z.object({
ok: z.literal(true),
input: profileSchema,
}),
z.object({
ok: z.literal(false),
formError: z.string().optional(),
fieldErrors: z.array(
z.object({
path: z.enum(["name", "email", "department"]),
message: z.string(),
}),
),
}),
])
type ServerProfileResult = z.output<typeof serverProfileResultSchema>
async function saveProfileWithValidation(
profile: Profile,
): Promise<ServerProfileResult> {
const response = await fetch(`/api/profiles/${profile.id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(profile),
})
const body: unknown = await response.json()
return serverProfileResultSchema.parse(body)
}Then apply only the validated field paths:
function ProfileWithServerValidation({
profile,
}: {
readonly profile: Profile
}) {
const [requestError, setRequestError] = useState<string>()
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: profile,
onSubmit: async ({ value, form }) => {
setRequestError(undefined)
form.api.clearErrors(["name", "email", "department"])
try {
const result = await saveProfileWithValidation(value)
if (result.ok) {
form.api.reset(result.input)
return
}
setRequestError(result.formError)
for (const [index, issue] of result.fieldErrors.entries()) {
form.api.setError(
issue.path,
{ type: "server", message: issue.message },
{ shouldFocus: index === 0 },
)
}
} catch {
setRequestError("The profile could not be saved")
}
},
})
return (
<nativeFormKit.AutoForm form={form}>
{requestError !== undefined && <p role="alert">{requestError}</p>}
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.AutoForm>
)
}Validate the server response before you apply it. Map only known server paths
to schema input paths. A generated control displays the error after the submit
attempt. shouldFocus moves focus to the first server field error.
Clear old server errors before the next request. Do not convert network, authorization, or service failures into field errors.
Send schema output and reset with schema input
Standard Schema can transform data. In that case, editable input and submitted output are different contracts.
function NormalizedProfileEditor({
profile,
}: {
readonly profile: NormalizedProfileInput
}) {
const form = nativeFormKit.useForm(normalizedProfileDefinition, {
defaultValues: profile,
onSubmit: async ({ value, form }) => {
// value has trimmed names and lower-case email addresses.
const savedInput = await saveNormalizedProfile(value)
// reset requires schema input, not transformed schema output.
form.api.reset(savedInput)
},
})
return (
<nativeFormKit.AutoForm form={form}>
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
</nativeFormKit.AutoForm>
)
}onSubmit receives these values:
inputis the currentFormInput<Schema>.valueis the parsedFormOutput<Schema>.formis the current form binding.submitteris the native submit control snapshot ornull.
Send value when the server expects parsed output. Pass schema input to
form.api.reset. If the server returns only output, convert it to editable
input at the application boundary.
Keep schema parsing deterministic. Form, Please parses once for validation and returns the transformed output from that parse. The schema must still be free of side effects because React Hook Form can validate it again after later user actions.
Choose the transport format
"Form, Please" does not serialize the submit value. Send JSON when the server accepts the parsed object:
async function postProfile(value: FormOutput<typeof profileSchema>) {
const response = await fetch("/api/profiles", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(value),
})
if (!response.ok) {
throw new Error("The profile could not be saved")
}
}Build FormData explicitly when the request includes a file:
const uploadSchema = z.object({
title: z.string().trim().min(1),
attachment: z.file().optional(),
})
function createUploadBody(value: FormOutput<typeof uploadSchema>): FormData {
const body = new FormData()
body.set("title", value.title)
if (value.attachment !== undefined) {
body.set("attachment", value.attachment)
}
return body
}Do not set the content-type header when fetch sends FormData. The browser
adds the multipart boundary. Handle non-success responses with the async
submission pattern above.
Pass request state through context
Load remote data before or beside the form. Convert the current request result
to ResourceState, then pass it through form context. fromResource converts
that state into synchronous UI values.
const directoryDefinition = directoryKit.defineForm(profileSchema, {
ui: [
{ kind: "field", path: "name", control: "text", label: "Name" },
{
kind: "field",
path: "department",
control: "select",
label: "Department",
description: departmentDescription,
props: departmentProps,
options: departmentOptions,
disabled: (_values, { context }) =>
context.departments.status !== "success",
},
],
})
function DirectoryProfile({ context }: { readonly context: DirectoryContext }) {
const form = directoryKit.useForm(directoryDefinition, {
defaultValues: emptyProfile,
context,
})
return <directoryKit.AutoForm form={form} />
}The example derives select options, description text, and disabled state from one department resource. Each resolver receives the complete schema input and the current context.
fromResource does not fetch, cache, retry, or cancel a request. The
application or request library owns that work. A resolver must return
immediately and must not update form state.
See Resource state for pending, success, error, refresh, and TanStack Query examples.
Choose disabled or read-only mode
Set form-wide interaction state when permissions or workflow state apply to all generated fields.
function ProfileByMode({
profile,
mode,
}: {
readonly profile: Profile
readonly mode: ProfileMode
}) {
const form = nativeFormKit.useForm(profileDefinition, {
defaultValues: profile,
disabled: mode === "disabled",
readOnly: mode === "read-only",
})
return (
<nativeFormKit.AutoForm form={form}>
{mode === "edit" && (
<nativeFormKit.Submit>Save profile</nativeFormKit.Submit>
)}
</nativeFormKit.AutoForm>
)
}disabled disables generated controls and array actions. It also prevents
kit.Form from submitting. readOnly requests read-only behavior from each
control, but it does not disable the submit button.
Omit the submit action outside edit mode. A custom control must implement both states according to its platform semantics.
Preserve the accessibility contract
Generated field slots connect labels, descriptions, and issue output to a control. A custom control must attach the supplied properties to its interactive element.
const currency = defineControl<number | undefined, CurrencyProps>({
component: CurrencyControl,
})
const billingKit = createFormKit({
controls: { ...createNativeControls(), currency },
slots: createDefaultSlots(),
})A custom control must:
- Attach
input.id,input.name, andinput.ref. - Attach
input["aria-describedby"]and exposemeta.invalid. - Call
setValueafter a valid control change. - Call
blurfrom its blur event. - Honor
disabled,readOnly, andrequired.
Custom slots must preserve semantic groups, labels, issue output, and usable buttons. Test keyboard input, focus, invalid state, and read-only behavior for each reusable custom control.
Test the public experience
Test the form through public package imports and visible behavior:
- Render the component that calls
kit.useForm. - Find controls by their labels and roles.
- Enter schema input and submit with the visible button.
- Assert issues, focus, parsed output, and pending UI.
- Test request and resource branches as application behavior.
Do not test private definition normalization or focus maps. Test a reusable
custom control once against ControlProps. Test each product form as a user
operates it.
Read the API reference for individual contracts. Read Controls and slots for design-system integration.