API
Use this page to find public imports, configuration, return values, and runtime
behavior. Form, Please adds typed UI definitions to React Hook Form. 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/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/layout.css and form-please/package.json are non-JavaScript
exports. React UI, history, and persistence entries are client modules.
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? }) 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. |
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 |
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: [
{
kind: "field",
path: "email",
control: "text",
label: "Email",
props: { type: "email", autoComplete: "email" },
},
{
kind: "field",
path: "plan",
control: "select",
label: "Plan",
options: [
{ value: "solo", label: "Solo" },
{ value: "team", label: "Team" },
],
props: {
emptyOption: { label: "Select a plan" },
},
},
{
kind: "field",
path: "seats",
control: "number",
label: "Seats",
props: { min: 1, max: 100, step: 1 },
},
{
kind: "field",
path: "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`,
},
})| Key | Callback data |
|---|---|
arrayAdd | { label } from the array node |
arrayRemove | Zero-based index and one-based position |
arrayMoveUp | Zero-based index and one-based position |
arrayMoveDown | Zero-based index and one-based position |
Each value can be a string or a callback that returns a string. Import
DefaultSlotsI18n and its related types from form-please/default-slots.
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",
},
})The i18n object supports addItem, removeItem, moveItemUp,
moveItemDown, and chooseFile. All properties are optional.
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: [
{
kind: "field",
path: "role",
control: "select",
label: "Role",
options: [
{ value: "developer", label: "Developer" },
{ value: "designer", label: "Designer" },
],
props: {
displayEmpty: true,
},
},
{
kind: "field",
path: "topics",
control: "autocomplete-multiple",
label: "Topics",
options: ["React", "TypeScript", "Accessibility"],
},
{
kind: "field",
path: "notifications",
control: "switch",
label: "Notifications",
},
{
kind: "field",
path: "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, (ui) => [...]) 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",
}),
teamHint,
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" }),
],
}),
])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. The object form
defineForm(schema, { ui: [...] }) remains supported and produces the same
normalized definition.
In addition to opaque fragment placements, the UI array accepts four ordinary node kinds:
| Kind | Required properties | Purpose |
|---|---|---|
field | path, control | Connect one schema input path to one registered control. |
section | id, children | Group nodes without changing their path scope. |
array | path, itemDefault, children | Repeat nodes in the scope of one array item. |
render | id, 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. 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 React Hook Form values. The required property changes
the UI state only. Put every data rule in the schema.
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 teamHint = {
kind: "render",
id: "team-hint",
component: TeamHint,
visible: (values) => values.plan === "team",
} satisfies RenderNode<ProfileInput, ProfileContext>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.
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. |
beforeUpdate | Adjust or cancel one managed value proposal before middleware. |
afterUpdate | Observe the final committed transaction after middleware unwinds. |
middleware | Ordered Redux-shaped value middleware, fixed for the hook lifetime. |
onSubmit | Receives parsed output, editable input, the current binding, and a submitter snapshot. |
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,
middleware: [keepPlanValuesConsistent],
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>
)
}Submission has this sequence:
kit.Formcaptures the editable input and native submitter snapshots.- The internal RHF resolver parses that input once with the Standard Schema.
onSubmitreceives{ value, input, form, submitter }after parsing succeeds.- A returned promise keeps
form.api.formState.isSubmittingtrue until it settles.
The definition schema is the only form-level resolver. "Form, Please" fixes
criteriaMode: "all", shouldUnregister: false, and RHF error focus.
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.
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. |
Form-wide interaction and focus bookkeeping remain private. Use form.api for
raw RHF operations that deliberately bypass managed middleware.
Managed update hooks
Use beforeUpdate for one form-local 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.
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} />
}The callbacks use their latest React render versions. 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.
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 a form'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 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. The configured submit slot always receives type="submit". |
kit.AutoForm | Renders Form, the error summary, Fields, and then its children. |
Fields and Submit must be descendants of the matching kit.Form.
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 submission runs. 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.