Skip to content
Form, Please

Form kits

A form kit is the rendering contract between Form, Please and your project's UI. Create one kit for the controls, structure, and grid that forms in the project share.

PartResponsibilityExample
ControlConnect one typed value to one interactive component.An email input, checkbox, or date picker.
SlotRender structure around controls and groups.A field wrapper, section grid, or submit button.

Choose a starting point

Do not build a custom kit when a shipped preset already matches the project. Choose the smallest starting point that covers the UI:

Project needStart withWhat you own
Semantic HTML with project CSSnativeFormKit from form-please/preset-nativeStyles only.
Native controls with some project componentscreateNativeControls() and createDefaultSlots()Only the controls or slots that you replace.
A project design systemdefineControl() and FormKitSlotsThe interactive components and form structure.
Material UI 9createMuiFormKit() from form-please/preset-muiThe Material UI theme and field definitions.

The following walkthrough uses the second path. It keeps the accessible default structure, adds one project control, and produces a kit that can render a form.

Build a project kit

1. Define one project control

Use defineControl<Value, OwnProps, Context, Option>() to define the control contract. Value must match the schema input type for each field that uses the control. OwnProps describes application-owned props for one field. Context supplies shared runtime data. Set Option only when the control displays a selectable collection.

"use client"
 
import { type ,  } from "form-please"
 
export type  = {
	readonly ?: string
}
 
export function ({
	,
	,
	,
	,
	,
	: ,
	,
	,
	,
}: <string | undefined, >) {
	return (
		<
			={["aria-describedby"]}
			={. || }
			={}
			={.}
			={.}
			={}
			={() =>
				(...() || )
			}
			={.}
			={}
			={.}
			={}
			={ ?? ""}
		/>
	)
}
 
export const  = <string | undefined, >({
	: ,
})

The example uses undefined for an empty value. This is a control decision. Choose an empty value that matches the schema input type.

2. Assemble and export the kit

Start with the shipped controls and slots. Add project controls to the registry before calling createFormKit:

const controls = {
	...createNativeControls(),
	uppercase,
}
 
export const projectKit = createFormKit({
	controls,
	slots: createDefaultSlots(),
})

Create the kit once at module scope and import that instance wherever forms are defined or rendered. createFormKit freezes snapshots of the registries and grid; a finished kit does not have a runtime extension API.

3. Define fields with the kit

The control registry keys become the allowed control values. TypeScript checks the schema path, control value type, props, and options together:

const projectDefinition = projectKit.defineForm(profileSchema, {
	ui: [
		{
			kind: "field",
			path: "displayName",
			control: "uppercase",
			label: "Display name",
			props: { placeholder: "ADA LOVELACE" },
		},
		{
			kind: "field",
			path: "age",
			control: "number",
			label: "Age",
			props: { min: 18, max: 120, step: 1 },
		},
		{
			kind: "field",
			path: "role",
			control: "select",
			label: "Role",
			options: [
				{ value: "admin", label: "Administrator" },
				{ value: "member", label: "Member" },
			],
			props: {
				emptyOption: { label: "Select a role" },
			},
		},
		{
			kind: "field",
			path: "active",
			control: "checkbox",
			label: "Active account",
		},
	],
})

This definition uses the project uppercase control together with the shipped number, select, and checkbox controls.

4. Bind and render a form

Use the same kit to create the form binding and render its generated UI:

export function ProjectProfileForm() {
	const form = projectKit.useForm(projectDefinition, {
		defaultValues: {
			displayName: "",
			age: undefined,
			role: undefined,
			active: false,
			members: [],
		},
		onSubmit({ value }) {
			console.log(value)
		},
	})
 
	return (
		<projectKit.AutoForm form={form}>
			<projectKit.Submit>Save profile</projectKit.Submit>
		</projectKit.AutoForm>
	)
}

projectKit.AutoForm renders the form and generated fields. projectKit.Submit uses the kit's submit slot. After validation succeeds, onSubmit receives the schema output in value.

All JavaScript package entries are React client modules. In a Next.js App Router project, keep the kit and the component that calls useForm behind a "use client" boundary.

Check the kit before adopting it

  1. Put the shared kit in one project module and import the same instance in forms.
  2. Confirm that TypeScript rejects a control whose value type does not match its schema path.
  3. Submit an invalid form and confirm that the first invalid control receives focus.
  4. Confirm that labels, descriptions, and visible errors are announced by a screen reader.
  5. Test disabled and readonly fields, plus add, move, and remove actions when the kit supports arrays.

The project kit is now usable. The remaining sections document the complete control and slot contracts for replacing more of the baseline.

Controls

A control receives a field value from React Hook Form. It renders the interactive component and sends typed values back to the form. It does not render the field label, description, or errors; the Field and ErrorMessage slots render that content.

Control props

ControlProps<Value, OwnProps, Context, Option> contains these properties:

PropertyPurpose
pathThe field path in the schema input.
valueThe current React Hook Form value.
setValue(value)Store a new typed value. Do not pass a browser event.
blur()Mark the field as blurred. Call it from the component blur event.
inputIDs, the field name, and the focus ref for the interactive element.
metaValidation and interaction state for the field.
propsThe readonly application-owned props from the definition.
optionsThe current readonly selectable collection, for option-capable controls.
contextThe deeply readonly context from kit.useForm.
disabledPrevent all interaction when this value is true.
readOnlyShow the value but prevent changes when this value is true.
requiredExpose the resolved UI requirement to the component.

Connect every applicable input property to the interactive element:

Input propertyRequired connection
input.idSet the element id. The field label uses this ID.
input.nameSet the element name. This also supports autofill and browser diagnostics.
input.refAttach it to the focusable element. Invalid submission uses this ref for focus.
input["aria-describedby"]Set aria-describedby. It connects descriptions and visible errors.

Set aria-invalid from meta.invalid. The value becomes true only when the form displays field errors.

The remaining metadata has these meanings:

MetadataMeaning
dirtyThe user modified the field value.
touchedThe user interacted with the field.
validatingReact Hook Form is validating the field.
errorsAll normalized field issues.
displayErrorsIssues that the form currently shows.
invaliddisplayErrors contains at least one issue.

Native text inputs support a readOnly property. Some components do not. A custom select, checkbox, or file control must prevent changes when readOnly is true.

Option controls

Mark the value written by a reusable option item with OptionValue<Value>. Declare that item as the fourth defineControl type argument. The marker has no runtime representation.

type RoleOption = {
	readonly id: OptionValue<string>
	readonly label: string
}
 
function RoleChoiceControl({
	value,
	setValue,
	blur,
	input,
	meta,
	options,
	disabled,
	readOnly,
	required,
}: ControlProps<string | undefined, unknown, unknown, RoleOption>) {
	return (
		<select
			aria-describedby={input["aria-describedby"]}
			aria-invalid={meta.invalid || undefined}
			aria-readonly={readOnly || undefined}
			disabled={disabled}
			id={input.id}
			name={input.name}
			onBlur={blur}
			onChange={(event) => {
				if (!readOnly) setValue(event.currentTarget.value || undefined)
			}}
			ref={input.ref}
			required={required}
			value={value ?? ""}
		>
			<option value="">Choose a role</option>
			{options.map((item) => (
				<option key={item.id} value={item.id}>
					{item.label}
				</option>
			))}
		</select>
	)
}
 
const roleChoice = defineControl<
	string | undefined,
	unknown,
	unknown,
	RoleOption
>({
	component: RoleChoiceControl,
})

The control keeps the broad string contract. When a form binds it to a schema path such as "admin" | "member", the field definition accepts only those IDs. For an array field, it uses the array element union. Nested option objects are not searched for choice collections.

Use as const or satisfies when options are declared outside the field node. Otherwise, TypeScript can widen a literal ID such as "admin" to string. Read Option values for definition examples.

Shipped native controls

createNativeControls() returns a fresh, frozen registry.

ControlValue typeProps
textstring | undefinedtype, placeholder, autoComplete
textareastring | undefinedplaceholder, autoComplete, rows
numbernumber | undefinedmin, max, step, placeholder
datestring | undefinedmin, max
timestring | undefinedmin, max, step
selectstring | undefinedOptional emptyOption; items come from field options.
checkboxbooleanNo props.
fileFile | undefinedaccept

The select control requires field options. Add props.emptyOption when the current value can be undefined. Do not combine emptyOption with an item whose value is an empty string.

Import option types from form-please/native-controls. Use nativeFormKit from form-please/preset-native when you need the complete native baseline.

Material UI controls

createMuiFormKit() supplies these controls:

ControlsValue typeProps type
text, textarea, password, email, url, tel, searchstring | undefinedMuiTextFieldProps
date, time, datetime-localstring | undefinedMuiTextFieldProps
numbernumber | undefinedMuiTextFieldProps
selectstring | undefinedMuiSelectProps
select-multiplereadonly string[]MuiSelectMultipleProps
radiostring | undefinedMuiRadioProps
autocompletestring | undefinedMuiAutocompleteProps
autocomplete-multiplereadonly string[]MuiAutocompleteMultipleProps
checkboxbooleanMuiCheckboxProps
switchbooleanMuiSwitchProps
fileFile | undefinedMuiFileProps
filesreadonly File[]MuiFileProps
slidernumberMuiSliderProps
range-sliderreadonly number[]MuiRangeSliderProps

Import these props types from form-please/preset-mui. The props types omit props that the kit owns, such as the current value, field name, disabled state, and input ref.

The field options accepted by select, radio, and autocomplete controls narrow to the selected schema path. This rule also applies to their multiple-value variants. Custom select children and autocomplete freeSolo values are application-owned and do not participate in option-value inference.

Value and submission rules

Controls store schema input values in React Hook Form. They do not serialize values for submission. A field name or a browser FormData value does not change the submitted result.

After validation succeeds, onSubmit receives the schema output, schema input, form binding, and submitter snapshot. Read Validation and submission for the complete sequence. Read Product workflows for named submit actions.

Slots

Slots own the form structure. A complete FormKitSlots registry contains six React components.

SlotRendersImportant props
FieldA label, description, control, and field errors.rootProps, labelProps, descriptionProps, control, errors
SectionA section heading and child layout.rootProps, layoutProps, title, description, children
ArrayAn array label, errors, items, and add action.canAdd, add, children, errors
ArrayItemOne array item and its move or remove actions.index, canMoveUp, canMoveDown, move, remove
ErrorMessageOne normalized form issue.rootProps, issue
SubmitThe kit submit button.buttonProps, values, isSubmitting

Field, Section, and Array also receive typed slotOptions. The form definition supplies these options for one node.

Slot prop reference

The slot types contain these props:

PropsSlotsPurpose
rootPropsField, Section, Array, ArrayItem, ErrorMessageAttributes and refs for the slot root.
slotOptionsField, Section, ArrayReadonly options from the current definition node.
label, labelPropsField, ArrayOptional label content and its generated attributes.
description, descriptionPropsField, ArrayOptional description content and its generated attributes.
controlFieldThe rendered control component.
errorsField, ArrayRendered ErrorMessage slot elements for visible issues.
disabled, readOnly, requiredFieldResolved interaction requirements for the field.
title, descriptionSectionOptional section content.
layoutPropsSectionGrid attributes for the child layout element.
invalidArrayWhether the array has visible issues.
canAdd, add()ArrayAdd-action state and callback.
indexArrayItemThe zero-based item index.
disabled, readOnlyArrayItemResolved interaction state for the item.
canMoveUp, canMoveDownArrayItemBoundary and interaction state for move actions.
move(toIndex), remove()ArrayItemArray mutation callbacks.
childrenSection, Array, ArrayItemRendered child nodes or items.
issueErrorMessageOne normalized FormIssue.
buttonPropsSubmitButton content, attributes, submit type, and resolved disabled state.
valuesSubmitThe current form input object.
isSubmittingSubmitWhether the submit handler has a pending promise.

Implement a field slot

Spread generated props on the matching elements. These props preserve labels, descriptions, focus behavior, CSS hooks, and resolved state.

function CardFieldSlot({
	rootProps,
	label,
	labelProps,
	description,
	descriptionProps,
	slotOptions,
	control,
	errors,
	required,
}: FieldSlotProps<CardFieldOptions>) {
	let requiredMark: ReactNode = null
	if (required) {
		requiredMark = <span aria-hidden="true"> *</span>
	}
 
	let labelNode: ReactNode = null
	if (label !== undefined) {
		labelNode = (
			<label {...labelProps} htmlFor={labelProps.htmlFor}>
				{label}
				{requiredMark}
			</label>
		)
	}
 
	let descriptionNode: ReactNode = null
	if (description !== undefined) {
		descriptionNode = <p {...descriptionProps}>{description}</p>
	}
 
	return (
		<div {...rootProps} data-tone={slotOptions?.tone ?? "default"}>
			{labelNode}
			{descriptionNode}
			{control}
			{errors}
		</div>
	)
}

Render control exactly once. Render each item in errors so users can read the displayed validation issues.

Implement an array slot

Call add() from a button with type="button". Disable the action when canAdd is false. The kit sets canAdd to false for disabled and readonly arrays.

function ListArraySlot({
	rootProps,
	label,
	labelProps,
	description,
	descriptionProps,
	slotOptions,
	errors,
	canAdd,
	add,
	children,
}: ArraySlotProps<ListSlotOptions>) {
	let labelId: string | undefined
	let labelNode: ReactNode = null
	if (label !== undefined) {
		labelId = labelProps.id
		labelNode = <h2 {...labelProps}>{label}</h2>
	}
 
	let descriptionId: string | undefined
	let descriptionNode: ReactNode = null
	if (description !== undefined) {
		descriptionId = descriptionProps.id
		descriptionNode = <p {...descriptionProps}>{description}</p>
	}
 
	return (
		<section
			{...rootProps}
			aria-describedby={descriptionId}
			aria-labelledby={labelId}
		>
			{labelNode}
			{descriptionNode}
			{errors}
			{children}
			<button disabled={!canAdd} type="button" onClick={add}>
				{slotOptions?.addLabel ?? "Add item"}
			</button>
		</section>
	)
}

The ArrayItem slot owns move and remove controls. Use canMoveUp and canMoveDown for move state. Also disable all item actions when disabled or readOnly is true.

Implement a submit slot

Spread buttonProps on the button. These props contain type="submit" and the resolved disabled state.

function SaveSubmitSlot({ buttonProps, isSubmitting }: SubmitSlotProps) {
	const { children, ...props } = buttonProps
	let content = children
	if (isSubmitting) {
		content = "Saving…"
	}
 
	return <button {...props}>{content}</button>
}

Use isSubmitting for pending content. Use values only when the button UI depends on the current form input.

Build the slot registry

createFormKit requires all six slots. Start with createDefaultSlots() when you only need to replace some slots.

const defaultSlots = createDefaultSlots()
 
const brandedSlots = {
	...defaultSlots,
	Field: CardFieldSlot,
	Array: ListArraySlot,
	Submit: SaveSubmitSlot,
} satisfies FormKitSlots<CardFieldOptions, never, ListSlotOptions>
 
const brandedKit = createFormKit({
	controls,
	slots: brandedSlots,
})

The generic parameters of FormKitSlots define options for Field, Section, and Array, in that order. Use never when a slot does not accept options.

Pass the typed options through slotOptions in the form definition:

const brandedDefinition = brandedKit.defineForm(profileSchema, {
	ui: [
		{
			kind: "field",
			path: "displayName",
			control: "uppercase",
			label: "Display name",
			slotOptions: { tone: "emphasis" },
		},
		{
			kind: "array",
			path: "members",
			label: "Team members",
			itemDefault: { name: "" },
			slotOptions: { addLabel: "Add team member" },
			children: [
				{
					kind: "field",
					path: "name",
					control: "text",
					label: "Name",
				},
			],
		},
	],
})

Generated structural props

rootProps contains stable attributes for styling and state inspection. Always spread it on the slot root element.

AttributeMeaning
data-fp-nodeThe structural node type.
data-fp-pathThe current field or array path, when applicable.
data-fp-spanThe resolved grid span, when applicable.
data-invalid, data-dirty, data-touchedCurrent validation and interaction state.
data-disabled, data-readonly, data-requiredCurrent interaction requirements.
data-validatingThe node is validating.

Section.layoutProps contains data-fp-layout="grid" and data-fp-columns. Spread it on the element that arranges the section children.

Import form-please/layout.css for optional responsive structure. Read Styling for the CSS contract.

Shipped slot sets

Use createDefaultSlots() for accessible HTML structure without a visual theme. Its i18n option changes array action labels.

Use createMuiFormKit() for Material UI 9 controls, slots, and a 12-column grid. The application still owns the Material UI theme.

Read the API reference for factory imports and localization types.