Skip to main content

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() and validateField() pass the current form values as yup context, so tests can read sibling values via ctx.options.context.
  • Hidden fields: errors for fields hidden by a condition are 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 foreigner is 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. Only validate(), validateField(), setErrors() or reset() replace it.
  • Submit values: submit() validates first, then passes the values as they are after validation (including dependency updates) to the submit handler. File and Blob values 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):

FieldOptionPurpose
anytooltipHelp content next to the label
selectdefaultValueValue to preselect
select optionsiconIcon per SelectOption
asyncSelecttypingPauseMs, loadOnOpen, maxRenderedOptions, initialLabelSearch debounce, lazy loading, render cap, label for the initial value before options load
dateallowManualInputAllow 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:

  • values
  • errors
  • touched
  • currentStep
  • totalSteps
  • isSubmitting
  • isValid
  • setFieldValue
  • setFieldTouched
  • validate
  • submit
  • reset
  • nextStep
  • prevStep

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.