Form Builder
Imports
import { FormBuilder, MultiStepFormBuilder } from '@codella-software/utils'
import { useFormBuilder } from '@codella-software/react'
import * as yup from 'yup'
FormBuilder
FormBuilder is created with FormBuilder.create<T>() or FormBuilder.fromConfig(config). Both wire up field dependencies.
yup is a peer dependency: install it in your app, so your schemas and the builder share one copy.
A dependency's value function runs when the form is built, on reset(), and whenever one of its fields changes. Return undefined to leave the value unchanged, and null or '' to clear it:
{
type: 'text',
name: 'schoolName',
label: 'School',
dependencies: {
fields: ['student'],
value: (values) => (values.student ? undefined : ''),
},
}
type LoginForm = {
email: string
password: string
}
const builder = FormBuilder.create<LoginForm>()
.addFields([
{ name: 'email', type: 'email', label: 'Email' },
{ name: 'password', type: 'password', label: 'Password' },
])
.setInitialValues({ email: '', password: '' })
.setValidationSchema(
yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
}),
)
.onSubmit(async (values) => {
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
})
})
Useful builder methods:
addField(field)addFields(fields)addFieldGroup(prefix, group)setInitialValues(values)setValidationSchema(schema)setLayout(layout)setButtons(buttons)onSubmit(handler)onReset(handler)onChange(handler)setValidationBehavior({ validateOnChange, validateOnBlur, validateOnMount })use(middleware)
Runtime methods:
setFieldValue(name, value)setFieldTouched(name, touched?)validate()validateField(name, value?)submit()reset(values?)fillWithFakeData()— dev-only, see below
Async-select and async-validation methods are also part of the public API, including:
loadFieldOptions(fieldName, searchTerm?, force?)validateFieldAsync(name, value?)cancelAsyncValidation(name)
Validation behaviour
- Yup context:
validate()andvalidateField()pass the current form values as yupcontext, so tests can read sibling values viactx.options.context. - Hidden fields: errors for fields hidden by a
conditionare dropped, including their child paths (address.city,items[0].qty). A builder with no registered fields (schema only) keeps every schema error. - Stale conditional errors: an error raised while a conditional rule applied is cleared automatically once a sibling change switches the rule off. For example, a PESEL required only while
foreigneris false stops blocking the form when the user becomes a foreigner. The cleanup only removes errors, so it never surfaces an error on a field the user hasn't reached. - Caller-owned errors: an error you set with
setFieldError()(a server-side rejection, for example) is never auto-cleared. Onlyvalidate(),validateField(),setErrors()orreset()replace it. - Submit values:
submit()validates first, then passes the values as they are after validation (including dependency updates) to the submit handler.FileandBlobvalues are passed by reference.
Dev tools
fillWithFakeData() fills every field with a plausible placeholder value, useful for skipping manual data entry while building a form. It's disabled by default and must be opted into via FormBuilderOptions:
const builder = FormBuilder.create<LoginForm>({
devTools: { fakeData: true },
}).addFields([
{ name: 'email', type: 'email', label: 'Email' },
{ name: 'password', type: 'password', label: 'Password' },
])
builder.fillWithFakeData()
Calling it without the flag enabled logs a warning and does nothing. Values are generated per field type (e.g. a random option for select/radio, a number within min/max, true for checkbox/switch); asyncSelect, file, custom, and dateRange fields are skipped since there's no generic value to fabricate for them. Only enable this flag in development.
Field types
Supported type values:
text, email, password, number, textarea, select, asyncSelect, combobox, checkbox, switch, radio, date, dateRange, time, file, array, custom
Options for renderers (all optional; the builder stores them, your UI decides how to use them):
| Field | Option | Purpose |
|---|---|---|
| any | tooltip | Help content next to the label |
select | defaultValue | Value to preselect |
select options | icon | Icon per SelectOption |
asyncSelect | typingPauseMs, loadOnOpen, maxRenderedOptions, initialLabel | Search debounce, lazy loading, render cap, label for the initial value before options load |
date | allowManualInput | Allow typing as well as picking |
Array fields
{
type: 'array',
name: 'contacts',
label: 'Contacts',
itemTemplate: [
{ name: 'name', type: 'text', label: 'Name' },
{ name: 'phone', type: 'text', label: 'Phone' },
],
minItems: 1,
addButtonLabel: 'Add contact',
itemLabel: (index) => `Contact ${index + 1}`,
emptyMessage: 'No contacts yet',
}
minItems/maxItems are checked in validate(). A schema error for the same field takes precedence; otherwise minItemsMessage/maxItemsMessage (default English) is used.
fields is accepted as an alias of itemTemplate, and addButtonLabel/removeButtonLabel as aliases of addLabel/removeLabel. Renderer hints: defaultCollapsed, showItemNumbers, emptyMessage.
Custom field components receive setFieldValue(name, value) in CustomFieldProps, so they can update other fields.
React hook
export function LoginFormView() {
const form = useFormBuilder({ builder })
return (
<form
onSubmit={(event) => {
event.preventDefault()
void form.submit()
}}
>
<input
value={form.values.email}
onChange={(event) => void form.setFieldValue('email', event.target.value)}
onBlur={() => form.setFieldTouched('email', true)}
/>
<input
value={form.values.password}
onChange={(event) => void form.setFieldValue('password', event.target.value)}
onBlur={() => form.setFieldTouched('password', true)}
/>
<button type="submit" disabled={form.isSubmitting}>
Sign in
</button>
</form>
)
}
useFormBuilder({ builder }) returns:
valueserrorstouchedcurrentSteptotalStepsisSubmittingisValidsetFieldValuesetFieldTouchedvalidatesubmitresetnextStepprevStep
nextStep and prevStep are placeholders on the plain hook. Actual multi-step orchestration lives in MultiStepFormBuilder.
MultiStepFormBuilder
type SignupForm = {
email: string
password: string
firstName: string
lastName: string
}
const wizard = MultiStepFormBuilder.create<SignupForm>()
.addSteps([
{
id: 'account',
title: 'Account',
fields: [
{ name: 'email', type: 'email', label: 'Email' },
{ name: 'password', type: 'password', label: 'Password' },
],
},
{
id: 'profile',
title: 'Profile',
fields: [
{ name: 'firstName', type: 'text', label: 'First name' },
{ name: 'lastName', type: 'text', label: 'Last name' },
],
},
])
.setInitialValues({
email: '',
password: '',
firstName: '',
lastName: '',
})
Public multi-step methods:
nextStep()previousStep()goToStep(stepIdOrIndex)validateCurrentStep()validateStep(stepId)validateAllSteps()submit()reset(values?)removeStep(stepId): also removes the step's fields from the form, so their values and errors stop blocking submit
getStepState(index) throws a RangeError for an out-of-range index. getCurrentStep() throws when no steps are defined.