API
Use this page to find public imports, configuration, return values, and runtime
behavior. Form, Please adds typed UI definitions to React Hook Form (RHF). Each form
binding exposes the complete React Hook Form API as form.api.
For exported TypeScript types and their relationships, read the TypeScript reference.
All TypeScript examples on this page come from one file. The documentation build type-checks that file against the public package entries.
Install
Install the package, its required React Hook Form peer, and React:
npm install form-please react-hook-form react react-domImport the optional layout styles when you use the supplied grid contract:
import "form-please/layout.css"Entry points
| Import | Runtime exports | Purpose |
|---|---|---|
form-please | defineControl, createFormKit, fromResource, matchResource, useSnapshot | Build kits, controls, definitions, resource resolvers, and external-store adapters. |
form-please/default-slots | createDefaultSlots | Create accessible, unstyled structural slots. |
form-please/devtools | FormPleaseDevtools | Mount RHF DevTools and inspect Form, Please UI, updates, options, and optional features. |
form-please/history | createHistoryMiddleware, useHistory | Add optional managed value history and journal transfer. |
form-please/native-controls | createNativeControls | Create the native HTML control registry. |
form-please/persistence | createPersistenceMiddleware, createLocalStorageAdapter, createDateCodec, usePersistence | Restore and autosave editable drafts through application storage. |
form-please/preset-native | nativeFormKit | Use the ready-made native kit with English slots. |
form-please/preset-mui | createMuiFormKit | Create a Material UI 9 kit. |
form-please/testing | createDefinitionTester | Inspect resolved definitions and exercise production managed updates without rendering React. |
form-please/layout.css and form-please/package.json are non-JavaScript
exports. React UI, devtools, history, and persistence entries are client modules.
The testing entry is a server-compatible headless module.
Mount FormPleaseDevtools only in development. See the Devtools guide
for its one-component integration and raw-data boundary.
createDefinitionTester
Import createDefinitionTester(definition, options) from
form-please/testing. It creates a stateful headless harness around the
production definition resolver, managed value coordinator, and field-options
executor.
| Member | Result |
|---|---|
current, values | Current readonly resolution and complete schema input. |
field(path), array(path), node(id) | Strict selectors with candidate-rich errors. |
setValue(path, value), update(recipe) | Managed control or imperative proposals through hooks and middleware. |
append(path), remove(path, index), move(path, from, to) | Managed generated-array proposals. |
setContext, setDisabled, setReadOnly, rerender | Re-resolve non-value form inputs without middleware. |
resolveOptions(path) | Resolve static or asynchronous field options and expose failures. |
Each operation returns a transition with before, after, and exact public
node changes. Managed transitions also report committed, transaction, and
the middleware result. Read Test definitions for complete examples
and boundaries.
useSnapshot
useSnapshot(store) subscribes a component to any external store with stable
subscribe(listener) and getSnapshot() functions. The snapshot type is
inferred from getSnapshot. Its result must stay Object.is-equal until the
store changes. The same getter supplies the server snapshot, so it must also be
safe and consistent during server rendering.
const storeSnapshot = { status: "ready" as const }
const store = {
getSnapshot: () => storeSnapshot,
subscribe: (_listener: () => void) => () => undefined,
}
function ExternalStoreStatus() {
const snapshot = useSnapshot(store)
return <output>{snapshot.status}</output>
}defineControl
defineControl<Value, OwnProps, Context, Option>() creates one control definition. Its
generic parameters connect the control to compatible schema paths, application-owned
props, selectable options, and form context.
type UppercaseProps = {
readonly placeholder?: string
}
function UppercaseControl({
value,
setValue,
blur,
input,
meta,
props: inputProps,
disabled,
readOnly,
required,
}: ControlProps<string | undefined, UppercaseProps>) {
return (
<input
aria-describedby={input["aria-describedby"]}
aria-invalid={meta.invalid || undefined}
disabled={disabled}
id={input.id}
name={input.name}
onBlur={blur}
onChange={(event) =>
setValue(event.currentTarget.value.toUpperCase() || undefined)
}
placeholder={inputProps.placeholder}
readOnly={readOnly}
ref={input.ref}
required={required}
value={value ?? ""}
/>
)
}
const uppercase = defineControl<string | undefined, UppercaseProps>({
component: UppercaseControl,
})The control component receives these props:
| Prop | Meaning |
|---|---|
path | Current RHF field path. Array indexes use dot notation. |
value | Current field value. Its type is the first defineControl generic. |
setValue(value) | Replace the current field value. |
blur() | Mark the field as blurred. Call it from the interactive element. |
input | Generated id, name, focus ref, and optional aria-describedby. |
meta | Dirty, touched, validating, error, and displayed-error state. |
props | Deeply readonly application-owned props from the field definition. |
options | Current readonly collection for an option-capable control. |
context | Deeply readonly context from useForm. |
disabled, readOnly | Resolved form, parent, and field interaction state. |
required | Resolved presentation state. It does not add schema validation. |
Attach all input properties to the interactive element. Call blur from its
blur event. Use meta.invalid for aria-invalid.
meta.errors contains all normalized field issues. meta.displayErrors
contains the issues that the generated UI currently displays. Before the first
submit, the UI displays issues only after the user touches the field.
The function freezes the returned definition. It rejects any, unknown, and
never as the control value type during type checking.
createFormKit
createFormKit({ controls, slots, grid?, errorDisplay? }) creates and freezes one complete
design-system contract.
const kit = createFormKit({
controls: {
...createNativeControls(),
uppercase,
},
slots: createDefaultSlots(),
grid: [1, 2, 4],
})| Option | Requirement |
|---|---|
controls | A record of control definitions. Control names become definition values. |
slots | Components for fields, sections, arrays, array items, errors, and submit buttons. |
grid | Optional positive integer list. It must include 1 and contain no duplicates. |
errorDisplay | Optional "all" or "first". Sets how many validation messages each field and array shows. Defaults to "all". |
The default grid is [1, 2, 3, 4]. The function sorts a custom grid and freezes
copies of the controls, slots, and grid. Add all controls before this call. A
kit does not support runtime extension.
The returned kit has these members:
| Member | Result |
|---|---|
controls, slots, grid | Frozen registry snapshots. |
defineFragment | Creates a reusable schema-owned UI fragment. |
defineForm | Creates a typed and normalized form definition. |
forContext | Returns a type-only context view of the same kit. |
useForm | Creates a component-local form binding. |
Form, Fields, Submit, AutoForm | Render the form and its generated UI. |
defineFragment
kit.defineFragment(schema, (ui) => [...]) returns a frozen fragment with the
exact input schema at fragment.schema and placement methods at
fragment.fields. The object form defineFragment(schema, { ui }) remains
supported. Fragment-authored resolvers receive the local fragment input. See
Reusable fragments for composition,
context, and array examples.
Native controls
createNativeControls() returns a fresh frozen registry on each call.
| Control | Value type | Props and selectable options |
|---|---|---|
text | string | undefined | type, placeholder, autoComplete |
textarea | string | undefined | placeholder, autoComplete, rows |
select | string | undefined | Field options; optional props.emptyOption |
radio | string | undefined | Field options |
checkbox | boolean | No props or options |
number | number | undefined | min, max, step, placeholder |
date | string | undefined | min, max |
time | string | undefined | min, max, step |
file | File | undefined | accept |
Use props.emptyOption when a select value can be undefined. Do not also add an
option whose value is an empty string.
const preferencesSchema = z.object({
email: z.string().optional(),
plan: z.enum(["solo", "team"]).optional(),
seats: z.number().optional(),
newsletter: z.boolean(),
})
const preferencesDefinition = nativeFormKit.defineForm(
preferencesSchema,
(ui) => [
ui.field("email", {
control: "text",
label: "Email",
props: { type: "email", autoComplete: "email" },
}),
ui.field("plan", {
control: "select",
label: "Plan",
options: [
{ value: "solo", label: "Solo" },
{ value: "team", label: "Team" },
],
props: {
emptyOption: { label: "Select a plan" },
},
}),
ui.field("seats", {
control: "number",
label: "Seats",
props: { min: 1, max: 100, step: 1 },
}),
ui.field("newsletter", {
control: "checkbox",
label: "Send product news",
}),
],
)Import the option types from form-please/native-controls. The entry exports
NativeTextProps, NativeTextareaProps, NativeSelectProps,
NativeNumberProps, NativeDateProps, NativeTimeProps, and
NativeFileProps. It also exports the related select and text types.
Default slots
createDefaultSlots() returns accessible field, section, array, array-item,
error, and submit structure without a visual theme.
const nativeControls = createNativeControls()
const localizedDefaultSlots = createDefaultSlots({
i18n: {
arrayAdd: "Add another item",
arrayRemove: ({ position }) => `Remove item ${position}`,
},
})
const localizedNativeKit = createFormKit({
controls: nativeControls,
slots: localizedDefaultSlots,
})Use i18n to replace any English array action label:
const fullyLocalizedSlots = createDefaultSlots({
i18n: {
arrayAdd: ({ label }) => {
if (typeof label === "string") return `Add ${label}`
return "Add item"
},
arrayRemove: ({ position }) => `Remove item ${position}`,
arrayMoveUp: ({ position }) => `Move item ${position} up`,
arrayMoveDown: ({ position }) => `Move item ${position} down`,
},
})Import DefaultSlotsI18n and its related types from
form-please/default-slots. Read
Localization for the keys and
their callback data.
Native preset
Use nativeFormKit when the native controls and default English slots are the
required baseline.
const readyNativeKit = nativeFormKitUse the factories when the application needs localized slots or additional controls.
Material UI preset
Install Material UI 9 and Emotion before you import the preset:
npm install @mui/material @emotion/react @emotion/styledcreateMuiFormKit() returns a new frozen kit with Material UI controls, slots,
and a 12-column grid.
const muiKit = createMuiFormKit({
i18n: {
addItem: "Add item",
removeItem: (position) => `Remove item ${position}`,
moveItemUp: (position) => `Move item ${position} up`,
moveItemDown: (position) => `Move item ${position} down`,
chooseFile: "Choose file",
},
})Read Localization for the keys
of the i18n option.
The registry contains these controls:
| Value type | Control names | Props type |
|---|---|---|
string | undefined | text, textarea, password, email, url, tel, search, date, time, datetime-local | MuiTextFieldProps |
number | undefined | number | MuiTextFieldProps |
string | undefined | select | MuiSelectProps |
readonly string[] | select-multiple | MuiSelectMultipleProps |
string | undefined | radio | MuiRadioProps |
boolean | checkbox, switch | MuiCheckboxProps or MuiSwitchProps |
string | undefined | autocomplete | MuiAutocompleteProps |
readonly string[] | autocomplete-multiple | MuiAutocompleteMultipleProps |
File | undefined | file | MuiFileProps |
readonly File[] | files | MuiFileProps |
number | slider | MuiSliderProps |
readonly number[] | range-slider | MuiRangeSliderProps |
Select, radio, and autocomplete field options narrow to the schema
input union at the selected path. Multiple controls use the array element
union. Custom select children and autocomplete freeSolo values remain outside
that inference contract.
const muiSettingsSchema = z.object({
role: z.string().optional(),
topics: z.array(z.string()),
notifications: z.boolean(),
priority: z.number(),
})
const muiSettingsDefinition = muiKit.defineForm(muiSettingsSchema, (ui) => [
ui.field("role", {
control: "select",
label: "Role",
options: [
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
],
props: {
displayEmpty: true,
},
}),
ui.field("topics", {
control: "autocomplete-multiple",
label: "Topics",
options: ["React", "TypeScript", "Accessibility"],
}),
ui.field("notifications", {
control: "switch",
label: "Notifications",
}),
ui.field("priority", {
control: "slider",
label: "Priority",
props: { min: 0, max: 10, step: 1 },
}),
])The preset also exports its control props, option, slot-option, and localization
types. The application owns ThemeProvider, CssBaseline, and theme
configuration. See the Material UI example for a complete
form.
defineForm
kit.defineForm(schema, source, options?) validates, normalizes, and freezes a
typed definition. The schema-bound field, section, array, and render
helpers create ordinary nodes without requiring an authored kind property.
The schema must implement Standard Schema. Field paths and control values use
the schema input type, before schema transformations.
const profileDefinition = profileKit.defineForm(
profileSchema,
(ui) => [
ui.section("identity", {
title: "Profile",
columns: 2,
children: [
ui.field("name", {
control: "uppercase",
label: "Display name",
props: { placeholder: "ADA" },
required: true,
}),
ui.field("yearsOfExperience", {
control: "text",
label: "Years of experience",
}),
ui.field("plan", {
control: "select",
label: "Plan",
readOnly: (_values, { context }) => !context.canEditPlan,
options: [
{ value: "solo", label: "Solo" },
{ value: "team", label: "Team" },
],
}),
ui.field("teamName", {
control: "text",
label: "Team name",
visible: (values) => values.plan === "team",
}),
ui.render("team-hint", {
component: TeamHint,
visible: (values) => values.plan === "team",
}),
ui.field("country", {
control: "text",
label: "Country",
description: countryDescription,
}),
],
}),
ui.array("speakers", {
label: "Speakers",
itemDefault: { name: "" },
children: (speaker) => [
speaker.field("name", { control: "text", label: "Name" }),
],
}),
],
profileUpdatePolicy,
)array.children receives another builder bound to the item scope. The root
builder and each array child callback run once while the definition is created;
they are not value resolvers.
A builder list accepts only nodes from the helpers of its own scope and
fragment placements. A root helper inside array.children causes a type
error. Use the { ui } object form to write nodes as objects.
The optional third argument owns the complete managed-update policy:
| Option | Effect |
|---|---|
beforeUpdate | Adjust or cancel one managed value proposal before middleware. |
middleware | Ordered Redux-shaped value middleware. |
afterUpdate | Observe the final committed transaction after middleware unwinds. |
The callbacks and a frozen copy of the middleware list become readonly
properties of the definition. Each form binding and definition tester creates
its own middleware instances from that fixed policy. Use the current
transaction.context for binding-specific data.
In addition to opaque fragment placements, the builder creates four ordinary node kinds:
| Helper | Required options | Purpose |
|---|---|---|
ui.field(path, options) | control | Connect one schema input path to one registered control. |
ui.section(id, options) | children | Group nodes without changing their path scope. |
ui.array(path, options) | itemDefault, children | Repeat nodes for a non-nullable object-array path. |
ui.render(id, options) | component | Insert form-local React content. |
Field, section, and array nodes support resolved labels or descriptions. They
also support visible, disabled, readOnly, className, span, and typed
slotOptions. Fields add required, typed control props, and selectable
options when the control supports them. Fields and arrays add
errorDisplay. Sections add title and columns.
Each supported property except field options accepts a static value or a synchronous resolver. An
ordinary node resolver receives the complete deeply readonly schema input and
{ context }; a fragment-authored resolver receives its local fragment input.
Form, Please resolves the complete tree after each value change. It throws when
an ordinary resolver returns a promise. Field options may be an async
({ values, context, signal }) => options function with tracked dependencies,
cancellation, and [] fallback behavior.
A request that rejects renders an empty collection. A resolver that successfully
returns a non-array value violates its contract and throws a TypeError through
React.
Hidden fields keep their values,
and required is UI-only.
An array child path is relative to one item. itemDefault accepts a complete
item or a function that returns one. Form, Please clones the value before each
insertion. Array paths use their current numeric index, while React keys use
RHF's stable field-array ID.
A render component inherits resolved interaction state:
function TeamHint({ disabled, readOnly }: RenderNodeProps) {
return (
<p
data-disabled={disabled || undefined}
data-readonly={readOnly || undefined}
>
Team accounts can invite additional collaborators.
</p>
)
}
const teamHintDefinition = nativeFormKit
.forContext<ProfileContext>()
.defineForm(profileSchema, (ui) => [
ui.render("team-hint", {
component: TeamHint,
visible: (values) => values.plan === "team",
}),
])The runtime rejects malformed schemas, unknown controls, invalid path syntax, duplicate node IDs, missing children, and grid values outside the kit grid.
A definition belongs to the exact kit that created it. Keep the definition
stable for the hook lifetime. To use another definition, remount the component
with a different React key.
forContext
kit.forContext<Context>() returns a type-only view of the same runtime kit. It
makes context mandatory in useForm. It also types resolvers and compatible
controls.
const profileKit = kit.forContext<ProfileContext>()The method does not create or clone a registry. Its context type must extend the current kit context.
Form, AutoForm, and Submit of a kit accept a binding of a narrower
forContext view. A shared component can render appKit.Submit for a binding
of type FormBinding<Schema, Context> with Context extends AppContext.
FormBinding itself stays invariant in its context.
useForm
kit.useForm(definition, options) creates a component-local FormBinding.
| Option | Requirement and effect |
|---|---|
defaultValues | Required complete synchronous schema input, fixed for the hook lifetime. |
context | Required after forContext<Context>(); otherwise optional. |
disabled | Disables generated controls and native form submission. |
readOnly | Locks generated controls but does not disable submission. |
mode | Optional RHF validation mode. Defaults to onSubmit. |
reValidateMode | Optional RHF revalidation mode. Defaults to onChange. |
delayError | Optional RHF error-display delay in milliseconds. |
errorDisplay | Optional "all" or "first". Overrides the kit errorDisplay for this form. |
onSubmit | Receives parsed output, schema input, the current binding, and a submitter snapshot. It is the shorthand for an implicit submit action. |
actions | Named submit actions. Each action has an onSubmit handler, an optional validate callback, and an optional implicit: true. Do not use it with onSubmit. |
Use FormSubmitDetails<Schema, Context> from form-please when a reusable
submit handler needs the exact { value, input, form, submitter } parameter
type.
| Property | Type | Source |
|---|---|---|
value | FormOutput<Schema> | The successful Standard Schema parse. |
input | FormInput<Schema> | A deep snapshot captured before validation. |
form | FormBinding<Schema, Context> | The binding that received the native submit. |
submitter | Readonly<{ name: string; value: string }> | null | A frozen native submit-control snapshot captured before validation. |
function ProfileEditor({ context }: { readonly context: ProfileContext }) {
const form = profileKit.useForm(profileDefinition, {
defaultValues,
context,
onSubmit: async ({ value, input, form }) => {
// `input.yearsOfExperience` is a string from React Hook Form.
// `value.yearsOfExperience` is the transformed number.
await saveProfile(value)
form.api.reset(input)
},
})
return (
<profileKit.AutoForm form={form}>
<button
onClick={() =>
form.update((draft) => {
draft.plan = "solo"
draft.teamName = undefined
})
}
type="button"
>
Use individual plan
</button>
<profileKit.Submit>Save profile</profileKit.Submit>
</profileKit.AutoForm>
)
}onSubmit runs only after the Standard Schema parse succeeds. See onSubmit
receives four values.
The definition schema is the only form-level resolver. Form, Please fixes
criteriaMode: "all", shouldUnregister: false, and RHF error focus.
errorDisplay changes only the messages that a field or array slot shows.
The node value applies first, then the useForm value, then the kit value.
With "first", the slot shows the first schema issue for the path, and the
control aria-describedby references only that message. formState.errors,
onSubmit, and the error summary keep all issues.
submitter is a frozen { name, value } snapshot or null for an implicit
submit. It is captured before validation and does not expose the live DOM
element. Read the product workflow tutorial
for named actions, async validation, and implicit-submit behavior.
Submit actions
actions connects each button to its own validator and handler:
const form = kit.useForm(definition, {
defaultValues,
actions: {
approve: { implicit: true, onSubmit: ({ value }) => approveRequest(value) },
decline: {
validate: ({ input }) =>
input.reason.trim() ? [] : [{ path: "reason", message: "Enter a reason." }],
onSubmit: ({ value }) => declineRequest(value),
},
},
})
<kit.Submit action={form.actions.decline}>Decline</kit.Submit>A submit attempt runs these steps:
- Form, Please locks submission and captures the input snapshot.
- It clears the issues of the previous action and the external issues.
- It runs the schema once.
- If the schema passes, it runs
validate({ input, value })of the selected action. - If
validatereturns no issues, it runsonSubmit({ value, input, form, submitter }).
Because validate runs only after the schema passes, the user can see schema
issues and action issues in two submit attempts. Action issues focus the first
invalid field. An edit of a path clears the action issues for that path.
Enter in a single-line field runs the implicit action. Without an implicit
action, Enter does not submit the form. At most one action can be implicit.
A native submit that does not come from kit.Submit also runs the implicit
action. onSubmit is the shorthand for actions: { submit: { implicit: true, onSubmit } }.
To render kit.Submit outside kit.Form, for example in a dialog footer, set
binding or action. Set the native form attribute to the Form id.
kit.Submit does not read the ID of the Form. Set an explicit id on the Form.
<>
<kit.Form form={form} id="review-form">
<kit.Fields />
</kit.Form>
<kit.Submit action={form.actions.decline} form="review-form">Decline</kit.Submit>
</>With the shorthand, Enter keeps native implicit submission and its submitter.
form.submission is an external store. useSnapshot(form.submission).action
gives the name of the running action, or null.
useSnapshot(form.submission).error gives the rejection of the last action
onSubmit as { value }, or null. The wrapper object keeps a rejection with
undefined or another falsy value. Form, Please catches only this rejection. An
error from the schema, from validate, or from the runtime is not caught. The
next accepted submit and setIssues set error to null. The React Hook Form
formState.isSubmitSuccessful is false after a rejection.
External issues
form.setIssues(issues, { focus }) replaces the external issues. Each
FormIssueInput<Schema> has a message and an optional typed path.
An issue without a path shows in the error summary. External issues show
immediately and do not block the next submit attempt. Action and external
issues cannot use a path under the React Hook Form root key; such a call
throws a TypeError.
focus | Effect |
|---|---|
| omitted | Focus does not move. |
| a field path | Focuses that field, or the error summary if the field is disabled, hidden, or not registered. |
true | Focuses the first invalid field in RHF registration order, or the error summary. |
An edit of a path clears the external issues for that path and for its parent
and child paths. Revalidation does not clear external issues. form.setIssues([])
and a kit.Form reset clear them. Check that a server response is current
before you call setIssues.
Use form-wide state for review or locked workflows:
function ProfileReview({ context }: { readonly context: ProfileContext }) {
const form = profileKit.useForm(profileDefinition, {
defaultValues,
context,
readOnly: true,
})
return (
<profileKit.Form form={form} aria-label="Profile review">
<profileKit.Fields />
<button type="reset">Restore initial values</button>
<profileKit.Submit disabled>Save profile</profileKit.Submit>
</profileKit.Form>
)
}FormBinding
A binding contains these public fields:
| Field | Purpose |
|---|---|
api | Unchanged typed RHF UseFormReturn for values, fields, validation, reset, and subscriptions. |
definition | Fixed definition captured during the first hook render. |
context | Current runtime context. |
update(recipe) | Apply one managed Immer recipe through value middleware. |
actions | Submit actions with stable identity for kit.Submit action. |
submission | External store with the running action name. |
setIssues(issues, options) | Replace the external issues and optionally move focus. |
Form-wide interaction and focus bookkeeping remain private. Use form.api for
raw RHF operations that deliberately bypass managed middleware.
Managed update hooks
Configure beforeUpdate in the third argument to defineForm for one
definition-owned rule that adjusts or cancels a managed proposal. Its Immer
draft starts with the proposed nextValues. Mutate the draft and return
nothing to continue, or return false to cancel the complete transaction.
Use afterUpdate to observe the final ValueTransaction after commit and the
synchronous middleware unwind. Its patches and nextValues include downstream
middleware changes.
const profileUpdatePolicy = {
beforeUpdate(draft, transaction) {
if (
!transaction.context.canEditPlan &&
transaction.source.type === "control" &&
transaction.source.path === "plan"
) {
return false
}
if (draft.plan === "solo") draft.teamName = undefined
},
afterUpdate(transaction) {
recordManagedValues(transaction.nextValues)
},
middleware: [keepPlanValuesConsistent],
} satisfies DefineFormOptions<typeof profileSchema, ProfileContext>The callbacks are fixed with the definition. Read current binding-specific data
from transaction.context. Both must return synchronously and cannot start
another managed update. An error from
beforeUpdate prevents commit. An error from afterUpdate propagates without
rolling committed values back. afterUpdate does not mean that React rendering
or asynchronous validation has finished.
Initial values, reset, direct form.api mutations, and application-owned
useFieldArray operations bypass both hooks. Use middleware instead when
several independent policies need explicit composition. Cancellation and
effective no-op proposals do not call afterUpdate.
Managed value middleware
Use middleware when a generated change and its dependent values must commit as
one proposal, or when work must run only after that proposal commits. Put the
ordered list in the third argument to defineForm.
Read Value middleware for runnable examples, live previews, ordering, cancellation, bypasses, 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 has the Redux shape api => next => transaction. The public types
are FormMiddleware, FormMiddlewareApi, FormMiddlewareNext,
ValueTransaction, ValueTransactionSource, ValuePatch, and
FormUpdateRecipe.
A transaction contains deeply readonly TypeScript views of previousValues
and derived nextValues. These views are not frozen or cloned as archival
snapshots. The transaction also contains Immer patches, current context,
and a discriminated source. Patch paths are segment arrays such as
["speakers", 0, "name"]; they are not RHF dot paths.
next(patches) reapplies those authoritative patches to previousValues,
commits the resulting values synchronously, and returns the terminal
transaction unless another middleware replaces that return value.
Middleware can:
- append or replace patches before calling
next; - cancel the complete proposal by returning without
next; - read final committed values and run post-change work after
nextreturns; - return a promise after calling
nextsynchronously.
One middleware can call next synchronously at most once. It cannot start a
nested api.update during an active transaction. A later update after awaited
post-commit work is allowed. An exception before next prevents the commit; an
exception after next does not roll it back.
form.update(recipe) creates a managed transaction with source update. Its
synchronous Immer recipe can mutate the draft or return a replacement, but not
both. A no-op recipe creates no transaction. Redux middleware may replace the
terminal return value, so form.update returns unknown.
Generated controls and form.update publish final values with one RHF
setValues call. Generated append, remove, and move preserve native
useFieldArray row IDs; their dependent patches join the same final React
render, although raw RHF subscribers can observe an intermediate array state.
Array middleware cannot change the source array length or order beyond the
source action. It must not change another generated array's structure.
Only the proposed generated array action can change generated array length or
order. setValues cannot synchronize the private row IDs in useFieldArray.
Use each array's generated action for a managed structural change. Use a raw
useFieldArray method only when the application deliberately bypasses
middleware.
Do not change generated array length or order from a generated control or
form.update. These entry points cannot synchronize useFieldArray row IDs.
Initial values, reset, and every direct form.api mutation bypass middleware.
Managed updates cannot remove a top-level value because RHF shallow-merges
roots; assign undefined when the schema permits it. Validation follows mode
and reValidateMode after commit, but managed validation does not preserve
delayError. RHF owns dirty and touched metadata.
Managed value history
createHistoryMiddleware(options?) and useHistory(form, feature) are exported
only from form-please/history. Configure the returned feature in the
definition's middleware list, then pass it and the form to the hook. The hook
result combines the exact form-specific handle with its reactive snapshot.
| Option | Default | Effect |
|---|---|---|
limit | 100 | Maximum retained history groups; accepts a non-negative integer or Infinity. |
groupWindow | 750 | Milliseconds in which control changes on one path share a group; zero disables grouping. |
The handle exposes getSnapshot, subscribe, undo, redo, seek, clear,
export, and import. Navigation and import return
Promise<HistoryOperationResult>. clear() retains current values as the only
position without changing the form.
History restore enters hooks and middleware with source history. The action
is undo, redo, seek, or import. Restore can remove optional top-level
keys because its RHF reset terminal replaces complete values. Ordinary
form.update keeps its existing top-level-removal restriction.
HistoryJournal<Input> version 1 contains complete input entries and a numeric
current index. Import checks the protocol and configured limit but does not
require every editable entry to pass Standard Schema validation. Read
Managed value history for state ownership, grouping, raw RHF
boundaries, restoration, and journal leaf behavior.
Form persistence
createPersistenceMiddleware(options) and usePersistence(form, feature) are
exported only from form-please/persistence. Configure the feature in the
definition's fixed middleware list, then pass it and the form to the hook. The
hook starts restore after mount and combines the exact handle with its reactive
snapshot.
| Option | Default | Effect |
|---|---|---|
adapter | Required | Keyed asynchronous load, save, and remove transport. |
key | Required | Application storage key. |
version | Required | Non-negative application data version. |
saveDelay | 500 | Trailing autosave delay in milliseconds. |
codecs | [] | Tagged encoders and decoders for explicit opaque values. |
migrate | None | Converts decoded data from another application version. |
onError | None | Observes failures with PersistenceErrorDetails, whose operation is restore, save, or clear. |
The handle exposes restore, start, flush, clear, getSnapshot, and
subscribe. Restore reports "applied", "empty", "cancelled",
"transformed", or "conflict". Read Form persistence for
lifecycle rules, envelope encoding, migrations, conflicts, and adapters.
Form, Fields, Submit, and AutoForm
| Component | Props and behavior |
|---|---|
kit.Form | Requires a binding from the same kit. Accepts native form props except owned submit, reset, action, and validation props. |
kit.Fields | Renders the resolved definition. It renders optional children after generated fields. |
kit.Submit | Accepts button props except type, an action from form.actions, and a binding. Without action, it runs the onSubmit shorthand action. Inside kit.Form, binding and action must match the surrounding form. Outside kit.Form, binding or action selects the form, and the native form attribute must match the Form id. The configured submit slot always receives type="submit". |
kit.AutoForm | Renders Form, the error summary, Fields, and then its children. |
Fields must be a descendant of the matching kit.Form. Submit must be a
descendant too, unless it has binding or action. AutoForm provides that
form context automatically.
Form prevents native submission and connects the Form, Please submit wrapper
to raw form.api.handleSubmit. A reset event calls form.api.reset(). It sets
noValidate because the Standard Schema owns validation.
Submit becomes disabled while validation or any action runs. Its render
function and slot receive isPending. It also follows
the form-wide disabled option and its own disabled prop.
Use RHF hooks and components directly when generated fields are only part of the screen. This example adds an application-owned field and watches generated values:
function BillingReferenceField() {
const id = useId()
const { field, fieldState } = useController<ProfileInput, "billingReference">(
{
name: "billingReference",
},
)
let errorId: string | undefined
if (fieldState.error !== undefined) errorId = `${id}-error`
return (
<div>
<label htmlFor={id}>Billing reference</label>
<input
{...field}
aria-describedby={errorId}
aria-invalid={fieldState.invalid || undefined}
id={id}
value={field.value ?? ""}
/>
{fieldState.error !== undefined && (
<p id={errorId} role="alert">
{fieldState.error.message}
</p>
)}
</div>
)
}
function ProfileWithCustomSummary({
context,
}: {
readonly context: ProfileContext
}) {
const form = profileKit.useForm(profileDefinition, {
defaultValues,
context,
})
const plan = useWatch({ control: form.api.control, name: "plan" })
const speakers = useWatch({ control: form.api.control, name: "speakers" })
const state = useFormState({ control: form.api.control })
let dirtyState = <output>Saved</output>
if (state.isDirty) dirtyState = <output>Unsaved changes</output>
return (
<profileKit.Form form={form}>
<profileKit.Fields />
<BillingReferenceField />
<output>Selected plan: {plan}</output>
<output>{speakers.length} speakers</output>
{dirtyState}
<profileKit.Submit>Save profile</profileKit.Submit>
</profileKit.Form>
)
}kit.Form provides form.api through RHF FormProvider. register,
Controller, useController, useWatch, useFormState, useFieldArray, and
useFormContext therefore share the generated form's state. Application-owned
fields must provide their own labels, descriptions, issue output, and focus
behavior. Their paths must still exist in the definition schema, but they do
not need a node in the definition.
Resource helpers
matchResource and fromResource handle the three ResourceState branches:
pending, success, and error.
const pendingCountries: CountryResource = { status: "pending" }
const loadedCountries: CountryResource = {
status: "success",
value: ["Canada", "Japan"],
}
const failedCountries: CountryResource = {
status: "error",
error: new Error("Country list is unavailable"),
}| Helper | Behavior |
|---|---|
matchResource(resource, cases) | Selects one result immediately from a resource value. |
fromResource(select, cases) | Creates a synchronous UI resolver from application-owned resource state. |
Every fromResource branch also receives the complete readonly form values and
resolver details.
const selectCountries: UiResolver<
CountryResource,
ProfileInput,
ProfileContext
> = (_values, { context }) => context.countries
const countryDescription = fromResource(selectCountries, {
pending: () => "Loading countries",
success: ({ value }, values) =>
`${value.length} countries available for the ${values.plan} plan`,
error: ({ error }) => error.message,
})Use matchResource when you need a standalone value:
function describeCountries(countries: CountryResource) {
return matchResource(countries, {
pending: () => "Loading",
success: ({ value }) => `${value.length} loaded`,
error: ({ error }) => error.message,
})
}Both helpers require pending, success, and error branches. Neither helper
fetches, caches, retries, cancels, or retains data. Read Resource
state for a TanStack Query adapter.
Read Production recipes for complete composition and submission patterns.
Reconcile a generated array
FormBinding.array(path).reconcile(keys, { key, create }) synchronizes one mounted
generated array with a record selection. Retained rows keep their editable data
and row metadata. See Reconcile selected rows for a complete example
and support limits.