Skip to content

Form

Size
3.32 kb
View source

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

  1. 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 value
    type 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 }
  2. 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 value
    type 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>()
  3. Build your form

    Use Control, Label, Description, and ErrorMessage components inside a Field 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.Field
    name="title"
    validate={(title) => title.length < 5 && 'Too short'}
    >
    <form.Label children="Title" />
    <form.Description children="Add a title to your new todo" />
    <form.Control>
    <input
    value={ctx.getFieldValue('title')}
    onChange={(e) => ctx.setFieldValue('title', e.target.value)}
    onBlur={() => ctx.setFieldTouched('title', true)}
    />
    </form.Control>
    <form.ErrorMessage />
    </form.Field>
    <form.Field
    name="description"
    validate={(title) => title.length < 20 && 'Too short'}
    >
    <form.Label children="Description" />
    <form.Description children="The description of your new todo" />
    <form.Control>
    <input
    value={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)&nbsp;
    {/* Add a button to go to the first error */}
    <button
    children="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.

  1. Generate a Shared form Instance

    import { defineForm } from 'crustack/form'
    export const form = defineForm()
  2. 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>
    <input
    value={ctx.value}
    onChange={(e) => ctx.setValue(e.target.value)}
    onBlur={() => ctx.setTouched(true)}
    />
    </form.Control>
    <form.ErrorMessage />
    </div>
    )
    }
  3. 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.Field
    name="title"
    validate={(title) => title.length < 5 && 'Too short'}
    >
    <Input label="Title" />
    </form.Field>
    <form.Field
    name="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 the validate 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 SubmissionError
async 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 form
const 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 value
type 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.

PropDefaulttypedescription
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.

PropDefaulttypedescription
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')
disabledfalse
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.

PropDefaulttypedescription
asChildfalse
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.
disabledfalse
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-attributetypedescription
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.

PropDefaulttypedescription
asChildfalsebooleanSkip rendering the default <label> tag and render the children directly.
Props and behaviors are merged.

data-attributetypedescription
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 the Control
  • the FieldArray element
  • the Form element
PropDefaulttypedescription
asChildfalsebooleanSkip rendering the default <p> tag and render the children directly.
Props and behaviors are merged.

data-attributetypedescription
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.

PropDefaulttypedescription
asChildfalsebooleanSkip 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-attributetypedescription
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.

PropDefaulttypedescription
children-ReactNodeThis component accepts children.
e.g. {isLoading && <PreventSubmit><Spinner /></PreventSubmit>}

form.useFormCtx

Access the type-safe form context.

Returnstypedescription
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.

Returnstypedescription
nameFieldName<TValues>The name of the field.
disabledbooleantrue when the field is disabled.
errorbooleantrue when the field validation fails.
idstringThe control id
invalidbooleantrue when the field is touched & disabled.
touchedbooleantrue when the field is touched.
valueTValueThe field value.
setTouchedfunctionSet the touched state of the field.
setValuefunctionSet the value of the field.

form.useFieldArrayCtx

Access the type-safe fieldArray context.

Returnstypedescription
nameFieldName<TValues>The name of the fieldArray.
disabledbooleantrue when the fieldArray is disabled.
errorbooleantrue when the fieldArray validation fails.
invalidbooleantrue when the fieldArray is touched & disabled.
touchedbooleantrue when the fieldArray is touched.
valueTValueThe fieldArray value.
setTouchedfunctionSet the touched state of the fieldArray.
setValuefunctionSet the value of the fieldArray.