Skip to main content

Form

A declarative, config-driven form that renders fields, manages their state, runs validation, handles conditional visibility, and calls your onSubmit with the collected values.

The states live in a useForm hook. the <Form> component is the renderer bound to it. You never create a useState per field.

Usage

import { Form, useForm, type FormFields } from '@easy-ui-react/easy-ui-react'

const fields = {
firstName: { type: 'input', label: 'First name', isRequired: true },
email: { type: 'input', kind: 'email', label: 'Email' },
country: {
type: 'selector',
label: 'Country',
options: [
{ value: 'fr', label: 'France' },
{ value: 'de', label: 'Germany' },
],
},
} satisfies FormFields

export function Example() {
const form = useForm(fields)
return (
<Form
form={form}
title="Create your profile"
description="Both the title and description are optional."
onSubmit={(values) => console.log(values)}
/>
)
}

The form ships with a default bordered, padded style. Override it via className / classNames.base (or the global wrappers.form.base). title and description are both optional.

Submitted values

onSubmit receives two payloads. It only runs once validation passes.

<Form form={form} onSubmit={(values, allValues) => { /* ... */ }} />
  • values — the fields that applied to this run. A field hidden by dependsOn or isHidden is absent. Its key is typed optional, so TypeScript makes you handle the absence.
  • allValues — every key, always present, holding the field's current value. For a hidden field that value is its initial one, which is exactly what form.values exposes live.
const fields = {
email: { type: 'input', label: 'Email' },
company: { type: 'input', label: 'Company', dependsOn: { email: null } },
} satisfies FormFields

onSubmit={(values, allValues) => {
values.email // string
values.company // string | undefined → the key may be missing
allValues.company // string → always there
}}

Whether a key is optional is decided by the declaration of dependsOn / isHidden, not by the field actually being hidden at submit time — which the compiler cannot know. A field whose isHidden always returns false therefore still gets an optional key, and you handle an undefined that never happens.

One refinement applies to values only: a required number field is typed number rather than number | null, since validation has already rejected an empty one. allValues, form.values and form.fields.x.value are readable before validation, so they keep number | null. There is no equivalent for strings or lists — TypeScript cannot express "non-empty string" or "non-empty array" without a tuple that is painful to consume.

You can read values or drive fields during completion via the form instance:

form.values.email // current value
form.fields.email.setValue('a@b.com')
form.fields.email.error // current error (or null)
form.reset()

Submission errors

When your onSubmit fails, the form displays the reason in an Alert under the title.

Mapping status codes to messages

You can map a message to a status code to make error handling easier. Define how to read the status code from the error — only you can know that — and the form displays the matching message whenever a submission fails with a status you declared. Error handling then becomes pure configuration.

<Form
form={form}
onSubmit={(values) => createAccount(values)}
getSubmitErrorStatus={(error: AxiosError) => error.response?.status.toString() ?? null}
submitErrorMessages={{
409: 'This email address is already taken',
500: 'Server error, please try again',
}}
/>
  • getSubmitErrorStatus returns the status read from the error — only you know where it lives;
  • submitErrorMessages maps that status to a message. Keys accept strings as well as numbers.

Errors the mapping does not cover

onUnhandledSubmitError receives them, and only them: an error already turned into a message never reaches it. Use it to handle everything the mapping does not cover.

const [error, setError] = useState<string | undefined>(undefined)

<Form
form={form}
error={error}
onSubmit={(values) => createAccount(values)}
getSubmitErrorStatus={(error: AxiosError) => error.response?.status.toString() ?? null}
submitErrorMessages={{ 409: 'This email address is already taken' }}
onUnhandledSubmitError={(submitError) =>
setError(`Your account could not be created (${submitError.message}).`)
}
/>

A 409 shows the mapped message; anything else lands in the callback, which receives the error itself so you can build the message from it and display it through the error prop. Note that submitError needs no annotation here: the type comes from the one written on getSubmitErrorStatus just above.

With no mapping at all, every failed submission goes to it.

Without this callback the error is rethrown, so you can catch it inside your own onSubmit as usual.

Three more rules govern the result:

  • error always wins over a mapped message, like the error prop of every field primitive;
  • a mapped error is cleared at the start of the next submit, so a fixed problem stops being reported;
  • if getSubmitErrorStatus itself throws on an error it did not expect, its own failure is discarded rather than replacing the real error, which is then treated as unmapped.

Declaring it once for the whole app

getSubmitErrorStatus depends on the shape of your errors, not on your forms — an HTTP client, an internal service raising its own errors, whatever your onSubmit calls. The same function therefore usually fits every form, so declare it once in defaults.form, with a set of generic messages beside it. A form then declares only what is specific to it: its submitErrorMessages are merged with the global ones key by key.

There, the parameter is a plain Error: the form calls it with whatever was thrown, so it cannot promise a more precise type. Narrow it yourself, with a type guard rather than a cast:

// global config
getSubmitErrorStatus: (error) => (axios.isAxiosError(error) ? String(error.response?.status) : null)

// or with your own error class
getSubmitErrorStatus: (error) => (error instanceof ApiError ? error.code : null)

A guard returns null for an error of another shape, which is then simply treated as unmapped. A cast would let the function crash on a missing property. On a form declaring its own, annotate the parameter and the type is inferred.

Field types

Each field declares a type (its category). The input type additionally accepts a kind mapped to the native <input type> (email, tel, password, url, ...).

TypeValue typeNotes
inputstringkind sets the native input type
selectorstring / string[]requires options; selectionMode: 'multiple' makes it a list
autocompletestring / string[]requires options; selectionMode: 'multiple' makes it a list
numbernumber | nullnull = empty (distinct from 0)
inputs-groupstring[] / (number | null)[]a repeatable row; itemsType: 'number' switches the shape
customstringrequires a render function

The value type is derived from the field declaration, so form.values and the submitted payloads are typed per field. This requires satisfies FormFields on your declaration, not : FormFields:

const annotated: FormFields = { email: { type: 'input' } }
const declared = { email: { type: 'input' } } satisfies FormFields

annotated.anything // FieldConfig — any key is allowed, every value has the widest type
declared.email // the input field, typed as a string field

An annotation does not describe the value, it replaces its type: typeof annotated is Record<string, FieldConfig>, so TypeScript no longer knows which keys exist, nor which member of the union each one is — type: 'input' widens to FieldType and the discriminant everything relies on is gone. satisfies checks the same conformance while keeping the inferred type intact.

Per-component props go under props:

const fields = {
email: { type: 'input', kind: 'email', label: 'Email', props: { variant: 'faded' } },
} satisfies FormFields

Multi-selection

Set selectionMode: 'multiple' on a selector or an autocomplete field. The value becomes a string[], and isRequired treats an empty list as empty.

const fields = {
skills: {
type: 'autocomplete',
selectionMode: 'multiple',
label: 'Skills',
isRequired: true,
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'go', label: 'Go' },
],
},
} satisfies FormFields

Repeatable rows (inputs-group)

Renders an InputsGroup driven by the form. initialValues defines the rows — how many, and which ones are protected — while the form owns their values.

const fields = {
aliases: {
type: 'inputs-group',
label: 'Aliases',
initialValues: [{ value: '', isRequired: true }, { value: '' }],
},
amounts: {
type: 'inputs-group',
itemsType: 'number',
label: 'Amounts',
initialValues: [{ value: null }],
props: { inputProps: { prefix: '$' } },
},
} satisfies FormFields

Three things differ from the other field types:

  • there is no defaultValue — the initial value derives from initialValues, which already carries both the row structure and the row values, and two competing sources would drift apart;
  • the two submitted payloads differ: values holds the list without its empty rows (the equivalent of the component's onNonEmptyValuesChange), while allValues keeps it raw, empty rows included. For a number group that also means values is typed number[] and allValues (number | null)[];
  • rows marked isRequired in initialValues are counted: the form requires at least that many non-empty entries before it submits. Two required rows means two filled entries. This is a count, not a per-position rule, because removing an optional row shifts the ones after it — and every row of a group holds the same kind of data anyway.

A field-level isRequired composes with that: it simply demands at least one non-empty entry.

Field-level validators run on the whole raw list, so they see the empty rows; props.validations runs on each row. Both can be used together. props.onNonEmptyValuesChange stays available if you want the filtered list live, while typing — the form's own round-trip is driven by the raw one, otherwise a row you just added would vanish as soon as it came back.

Validation

The Form owns validation. A validator returns null when valid, or an error message. The second argument gives access to all values for cross-field checks.

const fields = {
password: { type: 'input', kind: 'password', label: 'Password', isRequired: true },
confirm: {
type: 'input',
kind: 'password',
label: 'Confirm',
validators: [(value, values) => (value === values.password ? null : 'Passwords do not match')],
},
} satisfies FormFields

Validation runs on submit: an invalid form blocks onSubmit and displays the errors. isRequired fields must be non-empty. Customize the message of a required field with isRequiredMessage. Set a default for the whole app via defaults.requiredMessage in the defaults global config.

When errors appear (validateOn)

A field's error is only shown once the field has been touched. Pass validateOn to useForm to choose when that happens:

const form = useForm(fields, { validateOn: 'blur' })
ModeA field is validated / starts showing its error…
'submit' (default)after the first submit attempt
'blur'when it loses focus
'change'as the user types

Submitting always validates everything and blocks on errors, regardless of the mode. Once a field is touched, its error re-validates live as the value changes.

Conditional fields

Use dependsOn to show a field only when other fields match. null means "the referenced field is visible", a string means "visible and equal to this value". All keys must match (AND).

const fields = {
hosting: {
type: 'selector',
label: 'Hosting',
options: [
{ value: 'shared', label: 'Shared' },
{ value: 'custom', label: 'Custom domain' },
],
},
// Shown only when hosting === 'custom'
domain: { type: 'input', label: 'Domain', dependsOn: { hosting: 'custom' } },
// Shown only once "domain" is itself visible (nested dependency)
tld: { type: 'input', label: 'TLD', dependsOn: { domain: null } },
} satisfies FormFields

For advanced cases, use isHidden: (values) => boolean (the field is hidden when it returns true).

A hidden field is excluded from validation and from the submitted values, and its value is reset to its initial value.

Custom fields

A custom field plugs any component into the Form's state and validation through the render context.

const fields = {
color: {
type: 'custom',
defaultValue: '',
validators: [(value) => (value ? null : 'Please pick a color')],
render: (ctx) => (
<ColorPicker value={ctx.value} onChange={ctx.setValue} disabled={ctx.isDisabled} error={ctx.error} />
),
},
} satisfies FormFields

The context is { name, value, setValue, error, isDisabled }. A custom field value is always a string. parse it (e.g. Number(value)) if you need another type, or read it later from form.fields.color.value. Styling of custom fields is your responsibility.

Variants and colors

variant and color are set once on the form and cascade to every field and button. variant accepts the union of all variants (field variants + button variants). An element that does not support the given variant falls back to its own default. Both can be overridden per field in its definition via props, and per button via actions.submitProps / actions.cancelProps.

const fields = {
name: { type: 'input', label: 'Name' },
// Override variant
country: { type: 'selector', label: 'Country', options, props: { variant: 'bordered' } },
} satisfies FormFields

<Form form={form} onSubmit={onSubmit} variant="flat" color="primary" />

Loading and disabled state

isLoading and isSubmitting are two independent states, even though fields and buttons render the same way in both:

  • isLoading (a prop you control) means the form's resources are loading. For example while fetching a selector's options from an API. Set it to true until the data is ready. It puts every field into its loading state and disables the actions. It does not imply the form is being submitted.
  • isSubmitting (read from form.isSubmitting) is managed automatically: it is true only while an async onSubmit is running. It drives the submit button spinner and also puts the fields into their loading state.

In both cases the fields render the same loading state. What differs is the meaning (isLoading = resources loading; isSubmitting = submission in progress) and the submit button, which only spins during submission.

  • isDisabled disables every field and action.
  • loadingMessage / disabledMessage temporarily replace the description in the header while the form is loading / disabled (loading takes priority when both apply).
export function Example() {
const form = useForm(fields)
const { data, isPending } = useQuery(/* fetch the selector options */)

return (
<Form
form={form}
onSubmit={onSubmit}
title="Team member"
loadingMessage="Loading options…"
isLoading={isPending}
/>
)
}

Actions

The submit and cancel buttons sit at the bottom right, cancel to the left of submit. Submit defaults to primary / solid, cancel to default / light. Cancel is shown when onCancel is provided.

loadingLabel and submittingLabel optionally replace the submit label while the form is loading resources (isLoading) or submitting (isSubmitting). submittingLabel wins if both apply.

<Form
form={form}
onSubmit={onSubmit}
actions={{
submitLabel: 'Create account',
loadingLabel: 'Loading…',
submittingLabel: 'Creating…',
cancelLabel: 'Discard',
onCancel: () => form.reset(),
submitProps: { color: 'success' },
}}
/>

Resetting

The form clears itself back to its initial values in two situations, both on by default:

  • isResetOnSubmit — after a submit that actually went through. A failed validation never reaches onSubmit, and a rejected onSubmit leaves everything in place, so the user never loses what they typed because of an error.
  • isResetOnCancel — when the cancel button is pressed, before your own onCancel runs.

Set either to false to keep the values, which is what you want when the user should be able to pick up where they left off.

{/* the values survive both the submit and the cancel */}
<Form
form={form}
onSubmit={onSubmit}
isResetOnSubmit={false}
isResetOnCancel={false}
actions={{ onCancel: closePanel }}
/>

Resetting also clears the validation errors and remounts the fields, so no stale entry is left behind. You can still trigger it yourself at any time with form.reset().

Submitting from outside the form

isSubmitButtonHidden removes the built-in submit button. Use it when the action belongs somewhere else — a modal footer, a toolbar. If nothing is left to show, the actions row is not rendered at all, so it adds no spacing.

Give the form an id and point your own button at it with the native form attribute:

const formId = useId()

<>
<Form id={formId} form={form} onSubmit={onSubmit} actions={{ isSubmitButtonHidden: true }} />

<Button type="submit" form={formId} isLoading={form.isSubmitting}>
Save
</Button>
</>

Slots

Each field type has its own slot, alongside the container and action slots. Field slots are applied to the rendered component's root.

SlotDescription
basethe <form> root (default card style)
headerwrapper around title + description
titlethe title heading
descriptionthe description text
errorAlertthe submission error alert
fieldsWrappercontainer of all fields
inputFieldrendered input fields
selectorFieldrendered selector fields
autocompleteFieldrendered autocomplete fields
numberFieldrendered number fields
inputsGroupFieldrendered inputs-group fields
actionsfooter wrapping the buttons
submitButtonthe submit button
cancelButtonthe cancel button
<Form form={form} onSubmit={onSubmit} classNames={{ submitButton: 'w-full' }} />

These slots can also be set globally via EasyUIProvider under wrappers.form.

Props

PropTypeDefaultDescription
formFormInstanceThe instance returned by useForm(fields)
onSubmit(values, allValues) => void | Promise<void>Applicable fields, then every field (see above)
titlestringOptional heading at the top of the form
descriptionstringOptional text under the title
actionsFormActionsConfigLabels, onCancel, isSubmitButtonHidden, button props
errorstringSubmission error, rendered in an alert under the title
submitErrorMessagesRecord<string | number, string>Status code → message, merged with the global defaults
getSubmitErrorStatus(error) => string | nullReads the status out of a rejected onSubmit
onUnhandledSubmitError(error) => voidCalled only for errors the mapping did not cover
variantFormVariantCascades to every field/button (see below)
colorFormColorCascades to every field/button
isDisabledbooleanfalseDisables every field and action
isLoadingbooleanfalseThe form's resources are loading (see below)
loadingMessagestringReplaces the description while isLoading
disabledMessagestringReplaces the description while isDisabled
isResetOnSubmitbooleantrueResets the form after a submit that went through
isResetOnCancelbooleantrueResets the form when the cancel button is pressed
classNamestringClass on the <form> root
classNamesPartial<Record<FormSlots, string>>Per-slot classes
presetstringNamed preset from the global config

useForm(fields) returns

MemberDescription
fields[name]{ value, setValue, error, isVisible, isTouched }
valuescurrent values of every field
setValue(name, value) => void
getFieldState(name) => fields[name]
validatevalidate all visible fields, returns boolean
handleSubmitvalidate, then call the submit handler: resolves to false if validation failed, true otherwise
resetreset all fields to their initial values
isValidwhether the form currently passes validation
isSubmittingtrue while an async onSubmit is running
isDirtywhether any value differs from its initial value

Storybook

See the Form stories for live examples.