Form
Build Accessible and type-safe forms.
With defineForm
, you can easily create fully typed, accessible forms with built-in validation, submission state management, and more.
Quick start
-
Define your form types
Start by defining the structure of your form fields and submission data:
todo-form.tsx // define a form that has:// - a "title" field that has a string value// - a "description" field that has a string valuetype FormValues = { title: string; description: string }// The type of the submission data, returned by `onSubmit`type SubmissionData = { successMessage: string }// The type of the submission error, raised in `onSubmit`type SubmissionError = { errorMessage: string } -
Create a fully typed form
Pass the types to
defineForm
to generate type-safe form components.todo-form.tsx 'use client'import { defineForm } from 'crustack/form'// define a form that has:// - a "title" field that has a string value// - a "description" field that has a string valuetype FormValues = { title: string; description: string }// The type of the submission error, raised in `onSubmit`type SubmissionError = { errorMessage: string }// The type of the submission data, returned by `onSubmit`type SubmissionData = { successMessage: string }export const todoForm = defineForm<FormValues, SubmissionError, SubmissionData>() -
Build your form
Use
Control
,Label
,Description
, andErrorMessage
components inside aField
to build an accessibile form.todo-form.tsx import { todoForm } from './form'import { postTodo } from './api'function TodoForm() {return (<form.Root initialValues={{ title: '', description: '' }} onSubmit={postTodo}>{(ctx) => (<form.Form><form.Fieldname="title"validate={(title) => title.length < 5 && 'Too short'}><form.Label children="Title" /><form.Description children="Add a title to your new todo" /><form.Control><inputvalue={ctx.getFieldValue('title')}onChange={(e) => ctx.setFieldValue('title', e.target.value)}onBlur={() => ctx.setFieldTouched('title', true)}/></form.Control><form.ErrorMessage /></form.Field><form.Fieldname="description"validate={(title) => title.length < 20 && 'Too short'}><form.Label children="Description" /><form.Description children="The description of your new todo" /><form.Control><inputvalue={ctx.getFieldValue('description')}onChange={(e) => ctx.setFieldValue('description', e.target.value)}onBlur={() => ctx.setFieldTouched('description', true)}/></form.Control><form.ErrorMessage /></form.Field>{/* The submission state depends on `<form.Root onSubmit={...} />` */}{/* Its type has been defined upfront on `defineForm` */}{ctx.submission.isError && <div>{ctx.submission.error.errorMessage}</div>}{ctx.submission.isSuccess && (<div>{ctx.submission.data.successMessage}</div>)}{/* Show the error count after a failed submit attempt */}{!!ctx.hasErrors && !!ctx.tryToSubmitErrorCount && (<div>The form has {ctx.errorCount.all} error(s) {/* Add a button to go to the first error */}<buttonchildren="See the error"onClick={() => ctx.scrollToFirstTouchedError()}/></div>)}<button children="Submit" /></form.Form>)}</form.Root>)}
Reusable components
Encapsulate the update logic of frequently used components, like input fields, to create reusable form elements.
-
Generate a Shared form Instance
import { defineForm } from 'crustack/form'export const form = defineForm() -
Create an Input Component with
useFieldCtx
input.tsx export function Input(props: { label: string }) {const ctx = form.useFieldCtx()return (<div><form.Label>{label}</form.Label><form.Control><inputvalue={ctx.value}onChange={(e) => ctx.setValue(e.target.value)}onBlur={() => ctx.setTouched(true)}/></form.Control><form.ErrorMessage /></div>)} -
Use you component inside of a
Field
. Its state will be automatically handled.function MyForm() {return (<form.Root initialValues={{ email: '', password: '' }} onSubmit={postTodo}>{(ctx) => (<form.Form><form.Fieldname="title"validate={(title) => title.length < 5 && 'Too short'}><Input label="Title" /></form.Field><form.Fieldname="description"validate={(title) => title.length < 20 && 'Too short'}><Input label="Description" /></form.Field><button children="Submit" /></form.Form>)}</form.Root>)}
Validation (synchronous)
Why field based validation?
Field-based validation offers a simpler alternative to schema-based validation at the top level, which can become complex and redundant, often duplicating the form’s lifecycle logic. By validating fields individually, we reduce complexity and allow for more dynamic forms, such as multi-step forms or forms with conditional fields. Validation is only performed on fields that are currently mounted.
When is the validation executed?
Validation occurs whenever form values change. This ensures that dependent fields are updated based on the new values, maintaining form consistency.
When will the error message be displayed?
The
ErrorMessage
component will show the error string returned by thevalidate
function, but only when the field is touched. A field is marked as touched in two ways:
- Manually: By calling ctx.setFieldTouched(fieldName, true).
- Automatically: When the form is submitted, all fields are marked as touched.
Example
function MyTodoForm() { return ( <form.Root initialValues={{ email: '', password: '' }} onSubmit={postTodo}> {(ctx) => ( <form.Form> <form.Field name="title" validate={(title, formValues) => { if (title.length < 5) { return 'The title is too short' } if (title.length > formValues.description.length) { return 'The title should not be longer than the description' } }} > <Input label='Title'> <form.ErrorMessage /> </form.Field>
<form.Field name="description" validate={(description, formValues) => { if (description.length < 20) { return 'The description is too short' } if (description.length < formValues.title.length) { return 'The description should be longer than the title' } }} > <Input label='Description'> <form.ErrorMessage /> </form.Field>
{/* ... */} </form.Form> )} </form.Root> )}
Validation (async)
1. Form-level async validation
Validate the entire form based on the submission state.
import { defineForm } from 'crustack/form'import {Input} from './input'
type FormValues = { email: string; password: string }type SubmissionError = { message: { email?: string; password?: string } }
const form = defineForm<FormValues, SubmissionError, unknown>()
// Fake API call that rejects with a SubmissionErrorasync function submitForm() { return Promise.reject({ message: { email: 'invalid email', password: 'Your password must contain a special character.', }, })}
export function RegistrationForm() { return ( <form.Root initialValues={{ email: '', password: '' }} onSubmit={submitForm}> {(ctx) => ( <form.Form> <form.Field name="email" // validate the email with the SubmissionError validate={() => ctx.submission.error?.message.email} > <Input label='Email Address'> <form.ErrorMessage /> </form.Field>
<form.Field name="password" // validate the password with the SubmissionError validate={() => ctx.submission.error?.message.password} >
<Input label='Password'> <form.ErrorMessage /> </form.Field>
<button>Submit</button> </form.Form> )} </form.Root> )}
2. Field-level async validation
Check if an email is already in use before signing up.
'use client'import { Input } from './input'import { useQuery } from '@tanstack/react-query'import { useEffect } from 'react'import { defineForm } from 'crustack/form'import { timeout } from 'crustack/utils'import { useDebounceValue } from 'crustack/hooks'import { Spinner } from './spinner'import { validateEmailSync } from './validate'
// define the formconst form = defineForm<{ email: string; password: string }, unknown, unknown>()
export function SignupForm() { return ( <form.Root initialValues={{ email: '', password: '' }} onSubmit={console.log}> {() => ( <form.Form> <EmailField /> <PasswordField /> <button>Submit</button> </form.Form> )} </form.Root> )}
function PasswordField() { // your password field here}
function EmailField() { const { validateForm, ...ctx } = form.useFormCtx() const email = ctx.getFieldValue('email') const debounced = useDebounceValue(email, 500) /** * Using the synchronous email value instead of the debounced value * in order to avoid using stale cached results. * Using debounced.value in the queryKey will output the cached result * of the previous debounced.value while debounced.isPending. */ const validateEmailQuery = useQuery({ queryKey: ['email', email], // disable the query if there is a sync error, or the email is being debounced enabled: !debounced.isPending && !validateEmailSync(email), // Avoid throwing the validation response: tanstack-query doesn't cache errors queryFn: async () => { // touch the field to show the validation result ctx.setFieldTouched('email', true) await timeout(1000) if (email === 'valid@email.com') return '' return 'Email already exists' }, })
/** * Revalidate the form when the async validation is completed. * This will update all displayed error messages */ useEffect(validateForm, [ validateEmailQuery.data, validateEmailQuery.error, validateForm, ])
const showPendingState = // no pending state when the sync validation fails !validateEmailSync(email) && // otherwise show pending state (debounced.isPending || validateEmailQuery.isLoading)
function getEmailErrorMessage(value: string) { return ( // sync validation validateEmailSync(value) || // async validation, since we don't throw the validation result // to best use the cache, we use the `data` property validateEmailQuery.data || // async validation failed (eg. "Failed to fetch") (validateEmailQuery.isError && 'An unexpected error has occurred, please try again later.') ) }
return ( <form.Field name="email" validate={getEmailErrorMessage}> <Input label="Email Address" />
{!!showPendingState && ( // Prevent the form submission when the pending state is displayed <form.PreventSubmit> <Spinner /> </form.PreventSubmit> )}
<form.ErrorMessage /> </form.Field> )}
Field arrays
The field names support index access (e.g. 'fieldname.0'
).
form.FieldArray
is a special Field designed to contain multiple form.Field
components.
You can provide an error message for the entire form.FieldArray
using the validate
prop and by placing a form.ErrorMessage
beneath the FieldArray
A form.FieldArray
is considered touched when at least one of it’s children is touched.
1. Basic example
import { defineForm } from 'crustack/form'import { Input } from './input'
type FormValues = { items: string[] }
const form = defineForm<FormValues, any, any>()
export function GroceryForm() { return ( <form.Root initialValues={{ items: ['apples', 'meat'] }} onSubmit={console.log}> {(ctx) => ( <form.Form> <form.FieldArray name="items" validate={(value) => // validate the whole field array at once value.length < 3 && 'You must have at least 3 items' } > {ctx.getFieldArrayValue('items').map((item, index) => ( // You could also validate each single field as usual // by using the Field validate prop and placing an ErrorMessage <form.Field name={`items.${index}`} key={item}> <Input label={`Item ${index}`} /> </form.Field> ))} {/* The FieldArray's ErrorMessage */} <form.ErrorMessage /> </form.FieldArray> </form.Form> )} </form.Root> )}
2. With insert / delete
import { defineForm } from 'crustack/form'import { Input } from './input'
type FormValues = { items: string[]; newItem: string }
const form = defineForm<FormValues, any, any>()
export function GroceryForm() { return ( <form.Root initialValues={{ items: ['apples', 'meat'], newItem: '' }} onSubmit={console.log} > {(ctx) => ( <form.Form> <form.FieldArray name="items" validate={(value) => // validate the entire field array at once value.length < 3 && 'You must have at least 3 items' } > {ctx.getFieldArrayValue('items').map((item, index) => ( <form.Field name={`items.${index}`} key={item}> <Input label={`Item ${index}`} />
<button type="button" onClick={() => { ctx.setFieldArrayValue('items', (state) => { const items = [...state.items] items.splice(index, 1) return items }) ctx.setFieldArrayTouched('items', (touched) => { const touchedItems = [...(touched.items ?? [])] touchedItems.splice(index, 1) return touchedItems }) }} > Remove item </button> </form.Field> ))} {/* The FieldArray's ErrorMessage */} <form.ErrorMessage /> </form.FieldArray>
<form.Field name="newItem"> <Input label="New Item" />
<button type="button" onClick={() => { // add the newItem to the items ctx.setFieldArrayValue('items', [ ...ctx.getFieldArrayValue('items'), ctx.getFieldValue('newItem'), ]) // clear the newItem field ctx.setFieldValue('newItem', '') }} > Add new Item </button> </form.Field> </form.Form> )} </form.Root> )}
API Reference
defineForm
Expects no arguments other than the form types. Returns the elements of the form, ensuring 100% type safety.
// define a form that has:// - a "title" field that has a string value// - a "description" field that has a string valuetype FormValues = { title: string; description: string }// The type of the submission data, returned by `onSubmit`type SubmissionData = { successMessage: string }// The type of the submission error, raised in `onSubmit`type SubmissionError = { errorMessage: string }
const form = defineForm<FormValues, SubmissionError, SubmissionData>()
form.Root
Contains all the parts of the form.
Prop | Default | type | description |
---|---|---|---|
initialValues | (required) | FormValues | The initial values of the form |
children | (required) | (ctx: Ctx) => ReactNode | The children can receive the form context via renderProps |
onSubmit | (required) | (values: FormValues, ctx: Ctx) => MaybePromise<SubmissionData> | Must return the SubmissionData (under a promise or not), or throw the SubmissionError . The result is then accessible under ctx.submission.data and ctx.submission.error |
onChange | - | (values: FormValues, prev: FormValues, ctx: Ctx) => void | Call ctx.tryToSubmit() here if you want to submit on change |
onSubmitSuccess | - | (data: SubmissionData) => void | Called after onSubmit resolves |
onSubmitError | - | (error: SubmissionError) => void | Called after onSubmit throws or rejects |
onSubmitSettled | - | () => void | Called after onSubmitSuccess and onSubmitError |
onTryToSubmitError | - | (ctx: Ctx) => void | Called after tryToSubmit fails, when there are validation errors, or when PreventSubmit is rendered. Good place to call ctx.scrollToFirstTouchedError() |
form.Form
The same as a regular <form>
with additional internals.
A single <form.Root>
can contain several <form.Form>
.
Prefer the onSubmit
of <form.Root>
form.Field
Contains the Description
, Label
, ErrorMessage
and the Control
.
Does not render any html tag.
Prop | Default | type | description |
---|---|---|---|
name | (required) | FieldName | The field name, corresponding to a key in FormValues, or a key with index access for arrays (eg 'todo' or 'todo.0' ) |
disabled | false | boolean | Use this prop to disable the Control ; it also applies to the Description and Label , which receive the data-disabled attribute. The Control gets the aria-disabled attribute. |
validate | - | (value: FieldValue, values: FormValues) => string | false | null | undefined | The field validation function executed on every form state change. Return an error message when invalid; otherwise, return a falsy value. When a field is invalid, the Control , Description and Label receive the data-invalid attribute. The Control also receives the aria-invalid attribute |
form.FieldArray
Similar to Field
but renders an HTML element in order to semantically group your fields.
The FieldArray
can be associated a Description
and an ErrorMessage
.
A FieldArray is touched when at least one of it’s descendant Field
is touched.
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Skip rendering the default <div> tag and render the children directly.Props and behaviors are merged. |
name | (required) | FieldName | The field name, corresponding to a key in FormValues . |
disabled | false | boolean | Pass true to disable all descendant fields and controls. Works the same as for Field |
validate | - | (value: FieldValue, values: FormValues) => string | false | null | undefined | The same as for Field |
data-attribute | type | description |
---|---|---|
data-disabled | "true" | undefined | "true" when the FieldArray is disabled |
data-invalid | "true" | undefined | "true" when the FieldArray validation fails |
data-touched | "true" | undefined | "true" when at least one child Field is touched. |
form.Control
Passes accessibility-related props to its immediate child.
❌ Incorrect usage: Manually overriding the id and name provided by Control
<Control> <input id="search" name="search" /></Control>
✅ Correct usage: Let Control pass the id and name to the input.
<Control> <input /></Control>
form.Label
Label(s) associated to the Control
, to be used within a Field
or FieldArray
.
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Skip rendering the default <label> tag and render the children directly.Props and behaviors are merged. |
data-attribute | type | description |
---|---|---|
data-disabled | "true" | undefined | "true" when the Field is disabled |
data-invalid | "true" | undefined | "true" when the Field validation fails |
data-touched | "true" | undefined | "true" when the Field is touched |
form.Description
The Description
is associated to its closest valid parent. Valid parents are:
- the
Field
element. The association is done with theControl
- the
FieldArray
element - the
Form
element
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Skip rendering the default <p> tag and render the children directly.Props and behaviors are merged. |
data-attribute | type | description |
---|---|---|
data-disabled | "true" | undefined | "true" when the closest Field - FieldArray is disabled |
data-invalid | "true" | undefined | "true" when the closest Field - FieldArray validation fails |
data-touched | "true" | undefined | "true" when the closest Field - FieldArray is touched |
form.ErrorMessage
Displays the validation error message of the closest Field
or FieldArray
.
To ensure the error message is correctly read by screen readers,
the ErrorMessage
element should always be rendered.
For more details, refer to the MDN documentation.
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Skip rendering the default <p> tag and render the children directly.Props and behaviors are merged. Example <ErrorMessage asChild>{(error) => <div>{error}</div>}</ErrorMessage> |
children | (error) => error | (error: string | undefined) => ReactNode | Custom render for the error message |
data-attribute | type | description |
---|---|---|
data-disabled | "true" | undefined | "true" when the closest Field - FieldArray is disabled |
data-invalid | "true" | undefined | "true" when the closest Field - FieldArray validation fails |
data-touched | "true" | undefined | "true" when the closest Field - FieldArray is touched |
Example: Split the error message on several lines
<ErrorMessage> {(error) => error.split(';').map((slice) => <div key={slice}>{slice}</div>)}</ErrorMessage>
form.PreventSubmit
The form submission is considered invalid when this component is rendered.
Useful to prevent submission while loading some data.
Prop | Default | type | description |
---|---|---|---|
children | - | ReactNode | This component accepts children. e.g. {isLoading && <PreventSubmit><Spinner /></PreventSubmit>} |
form.useFormCtx
Access the type-safe form context.
Returns | type | description |
---|---|---|
errorCount.all | number | The count of all touched and untouched errors. |
errorCount.touched | number | The count of all touched errors. |
getFieldError | (field:FieldName) => string | undefined | Get a field’s error message. |
getFieldTouched | (field:FieldName) => boolean | undefined | Get a field’s touch state. |
getFieldValue | (field:FieldName) => FieldValue | Get a field’s current value. |
getFieldArrayError | (field:FieldArrayName) => string | undefined | Get a fieldArray’s error message. |
getFieldArrayTouched | (field:FieldArrayName) => boolean[] | undefined | Get a fieldArray’s touch state. |
getFieldArrayValue | (field:FieldArrayName) => FieldArrayValue | Get a fieldArray’s current value. |
hasErrors | boolean | Indicates if the form has any error. |
resetValues | (updater: FormValues | ((prev: FormValues) => FormValues)) => void | Reset the form values. |
resetTouched | (updater: FormTouched | ((prev: FormTouched) => FormTouched), ) => void | Reset the form touched state. |
scrollToFirstTouchedError | (options: ScrollIntoViewOptions & { scrollMarginTop?: string | number }) => void | Scroll to the first data-invalid element. |
scrollToFirstTouchedError | (options: ScrollIntoViewOptions & { scrollMarginTop?: string | number }) => void | Scroll to the first data-invalid element. |
setFieldTouched | (field: FieldName, value: boolean | undefined) => void | Set a field’s touched state. |
setFieldValue | (field: FieldName, updater: FieldValue | ((values: FormValues) => FieldValue) | Set a field’s value. |
setFieldArrayTouched | (field: FieldArrayName, value: boolean[] | undefined) => void | Set a fieldArray’s touched state. |
setFieldArrayValue | (field: FieldArrayName, updater: FieldArrayValue | ((values: FormValues) => FieldArrayValue) | Set a fieldArray’s value. |
setValues | (updater: Partial<FormValues> | ((prev: FormValues) => Partial<FormValues>)) => void | Set the form values. Partial FormValues are shallowly merged. |
setTouched | (updater: Partial<FormTouched> | ((prev: FormTouched) => Partial<FormTouched>), ) => void | Set the form touched state. Partial FormTouched are shallowly merged. |
submission.error | SubmissionError | The error thrown or rejected by the Root onSubmit handler. |
submission.isError | boolean | true when there is a submission.error . |
submission.isLoading | boolean | true the Root onSubmit async handler pending. |
submission.isSuccess | boolean | true the Root onSubmit handler resolved or returned without throwing. |
submission.data | SubmissionData | The data returned or resolved by the Root onSubmit handler. |
submission.values | FormValues | The form values used to trigger the last Root onSubmit call. |
touched | FormTouched | The current FormTouched . Prefer getFieldTouched in most cases. |
tryToSubmit | () => void | Manually try to submit the form. Submission will be prevented if hasErrors is true or if the PreventSubmit component is mounted. |
tryToSubmitErrorCount | number | Count of how many times the user attempted to submit with a validation error. |
validateForm | () => void | Validate the entire form on demand based on the current values. Does not touch the fields |
values | FormValues | The current FormValues . Prefer getFieldValue in most cases. |
form.useFieldCtx
Access the type-safe field context.
Returns | type | description |
---|---|---|
name | FieldName<TValues> | The name of the field. |
disabled | boolean | true when the field is disabled. |
error | boolean | true when the field validation fails. |
id | string | The control id |
invalid | boolean | true when the field is touched & disabled. |
touched | boolean | true when the field is touched. |
value | TValue | The field value. |
setTouched | function | Set the touched state of the field. |
setValue | function | Set the value of the field. |
form.useFieldArrayCtx
Access the type-safe fieldArray context.
Returns | type | description |
---|---|---|
name | FieldName<TValues> | The name of the fieldArray. |
disabled | boolean | true when the fieldArray is disabled. |
error | boolean | true when the fieldArray validation fails. |
invalid | boolean | true when the fieldArray is touched & disabled. |
touched | boolean | true when the fieldArray is touched. |
value | TValue | The fieldArray value. |
setTouched | function | Set the touched state of the fieldArray. |
setValue | function | Set the value of the fieldArray. |