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 bydependsOnorisHiddenis 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 whatform.valuesexposes 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',
}}
/>
getSubmitErrorStatusreturns the status read from the error — only you know where it lives;submitErrorMessagesmaps 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:
erroralways wins over a mapped message, like theerrorprop of every field primitive;- a mapped error is cleared at the start of the next submit, so a fixed problem stops being reported;
- if
getSubmitErrorStatusitself 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, ...).
| Type | Value type | Notes |
|---|---|---|
input | string | kind sets the native input type |
selector | string / string[] | requires options; selectionMode: 'multiple' makes it a list |
autocomplete | string / string[] | requires options; selectionMode: 'multiple' makes it a list |
number | number | null | null = empty (distinct from 0) |
inputs-group | string[] / (number | null)[] | a repeatable row; itemsType: 'number' switches the shape |
custom | string | requires 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 frominitialValues, which already carries both the row structure and the row values, and two competing sources would drift apart; - the two submitted payloads differ:
valuesholds the list without its empty rows (the equivalent of the component'sonNonEmptyValuesChange), whileallValueskeeps it raw, empty rows included. For anumbergroup that also meansvaluesis typednumber[]andallValues(number | null)[]; - rows marked
isRequiredininitialValuesare 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' })
| Mode | A 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 totrueuntil 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 fromform.isSubmitting) is managed automatically: it istrueonly while an asynconSubmitis 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.
isDisableddisables every field and action.loadingMessage/disabledMessagetemporarily 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 reachesonSubmit, and a rejectedonSubmitleaves everything in place, so the user never loses what they typed because of an error.isResetOnCancel— when the cancel button is pressed, before your ownonCancelruns.
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.
| Slot | Description |
|---|---|
base | the <form> root (default card style) |
header | wrapper around title + description |
title | the title heading |
description | the description text |
errorAlert | the submission error alert |
fieldsWrapper | container of all fields |
inputField | rendered input fields |
selectorField | rendered selector fields |
autocompleteField | rendered autocomplete fields |
numberField | rendered number fields |
inputsGroupField | rendered inputs-group fields |
actions | footer wrapping the buttons |
submitButton | the submit button |
cancelButton | the cancel button |
<Form form={form} onSubmit={onSubmit} classNames={{ submitButton: 'w-full' }} />
These slots can also be set globally via EasyUIProvider under wrappers.form.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
form | FormInstance | — | The instance returned by useForm(fields) |
onSubmit | (values, allValues) => void | Promise<void> | — | Applicable fields, then every field (see above) |
title | string | — | Optional heading at the top of the form |
description | string | — | Optional text under the title |
actions | FormActionsConfig | — | Labels, onCancel, isSubmitButtonHidden, button props |
error | string | — | Submission error, rendered in an alert under the title |
submitErrorMessages | Record<string | number, string> | — | Status code → message, merged with the global defaults |
getSubmitErrorStatus | (error) => string | null | — | Reads the status out of a rejected onSubmit |
onUnhandledSubmitError | (error) => void | — | Called only for errors the mapping did not cover |
variant | FormVariant | — | Cascades to every field/button (see below) |
color | FormColor | — | Cascades to every field/button |
isDisabled | boolean | false | Disables every field and action |
isLoading | boolean | false | The form's resources are loading (see below) |
loadingMessage | string | — | Replaces the description while isLoading |
disabledMessage | string | — | Replaces the description while isDisabled |
isResetOnSubmit | boolean | true | Resets the form after a submit that went through |
isResetOnCancel | boolean | true | Resets the form when the cancel button is pressed |
className | string | — | Class on the <form> root |
classNames | Partial<Record<FormSlots, string>> | — | Per-slot classes |
preset | string | — | Named preset from the global config |
useForm(fields) returns
| Member | Description |
|---|---|
fields[name] | { value, setValue, error, isVisible, isTouched } |
values | current values of every field |
setValue | (name, value) => void |
getFieldState | (name) => fields[name] |
validate | validate all visible fields, returns boolean |
handleSubmit | validate, then call the submit handler: resolves to false if validation failed, true otherwise |
reset | reset all fields to their initial values |
isValid | whether the form currently passes validation |
isSubmitting | true while an async onSubmit is running |
isDirty | whether any value differs from its initial value |
Storybook
See the Form stories for live examples.