Form abstracts form validation logic away from the view code

General

Form is a wrapper around an HTML form element and the react-hook-form library. It manages the call to useForm(), renders a <FormProvider> and manages validation. When validation happens depends on the validationMode prop, which accepts "onBlur" | "onSubmit" and defaults to "onBlur".

Form is compatible with all our field components (i.e. InputField, not Input) and is also compatible with custom fields so long as they call useFormContext() and use:

  • register or control to register with the form per react-hook-form’s docs
  • errors to display any errors for the relevant field

Custom field components should also accept a validation prop of type ValidationOptions and apply it to the field when present.

Here’s an example using a simplified version of our InputField:

const InputField = ({ label, name, validation }) => {
const { register, errors } = useFormContext()
const ref = validation ? register(validation) : register
const error = errors[name]?.message
return (
<FieldWrapper label={label} error={error} fieldId={name}>
<Input id={name} name={name} ref={ref} />
</FieldWrapper>
)
}

Form submission

To handle form submission, onSubmit and optional onError callback props can be passed to Form. If there are any validation errors present onError will be called, in case of no errors the onSubmit will be called instead.

ValidationOptions

The ValidationOptions type provides preset logic for common validation and processing patterns. You can also define your own custom logic.

Basic validation

The required property accepts a message and a value. value is a boolean that represents whether the field is required. That pattern is only necessary when deciding dynamically whether to make a field required. If it will always be required, you can pass a string containing the error message instead. The following two inputs are equivalent:

<Form>
<InputField
name="name"
label="Name"
validation={{
required: {
value: true,
message: 'You must have a name'
}
}}
/>
<InputField
name="name"
label="Name"
validation={{
required: 'You must have a name'
}}
/>
<Button>Submit</Button>
</Form>

The following properties take a message and a value:

  • min
  • max
  • minLength
  • maxLength
  • pattern

For example:

<Form>
<InputField
type="number"
name="numCats"
label="Number of cats"
validation={{
min: {
value: 3,
message: 'You must have at least 3 cats!'
}
}}
/>
<InputField
name="faveName"
label="Favourite cat name"
validation={{
maxLength: {
value: 20,
message: 'Cats hate long names!'
}
}}
/>
<Button>Submit</Button>
</Form>

Note that when specifying a pattern, the value is a RegExp. For the other basic properties listed here, value is a number.

Custom validation

The validate property accepts a function of type (value: any) => boolean | string. Its single argument is the current value of the field. It should return true if the value passes validation and a string containing an error if not.

<Form>
<InputField
name="faveVowel"
label="Favourite vowel"
validation={{
validate: (value) => {
return (
['A', 'E', 'I', 'O', 'U'].contains(value) || 'That’s not a vowel!'
)
}
}}
/>
<Button>Submit</Button>
</Form>

Basic processing

The following properties accept boolean values:

  • valueAsNumber
  • valueAsDate

These properties only apply to text inputs. If true, the value will be converted to the relevant type after validation.

Custom processing

The process property accepts a function of type (value: any) => any. It can be used to process the value after validation.

Persisting form data

Form is integrated with react-hook-form-persist's package to save form data into session storage:

  • send persist prop to Form component to persist form data in sessionStorage
  • persist object has a required id, an optional storage prop and optional include or exclude arrays with reference to the input's names
  • include will only save the listed inputs to session storage and exclude will exclude the listed inputs
  • if both include and exclude are sent it will ignore include and use only exclude
  • if no storage prop is sent, it will default to use sessionStorage, however you can override this by sending local to use localStorage
  • persisted form data in sessionStorage or localStorage will populate form with defaultValues on refresh

Here's an example using the persist prop:

<Form persist={{ id: 'nameAndSecret', exclude: ['secret'], storage: 'local' }}>
<InputField
name="name"
label="Name"
validation={{ required: 'Name is required' }}
/>
<InputField
name="secret"
label="Secret"
validation={{ required: 'Secret is required' }}
/>
<Button type="submit">Submit</Button>
</Form>

Composing form field components

It’s easy to compose your own custom form fields from the low-level components in this library (Input, Label, InlineMessage etc) but there are some requirements that your new field must meet in order to be fully compatible with our Form.

Props

Your custom form field component must take the props specified in General

error

error should be displayed in a InlineMessage when present. You don’t need to pass error to your component manually; the Form will pass it in automatically when appropriate.

To properly retrieve the error in both static and dynamic fields it is necessary to use the useFieldError hook stored in the form directory, that accepts a field name parameter.

validation

validation allows users of your component to add custom validation rules to it as specified above

register

If you don’t need to add any default validation to your component, then pass register?.(validation) down to the Input (or Select, etc) component’s ref prop. If you do want to add default validation, pass something like this:

register?.({
maxLength: { value: 2, message: 'short data only!' },
...validation
})

(but check that validation is truthy first, or default it to an empty object!). Note that optional chaining is important here to protect from runtime errors if your field gets rendered outside of a Form.

Examples

Here are two email input field examples. The first is a wrapper around an InputField and the second is manually composed from lower-level components. If wrapping InputField is an option then you should take it, but for more complex use cases you can use the manual composition here as a guide.

const EmailField = ({ name, register, error, validation = {}, ...rest }) => (
<InputField
name={name}
css={{ mb: '$3' }}
autoComplete="email"
label="Email address"
type="email"
register={register}
validation={{
required: 'email address is required',
pattern: {
value: /[email protected]+\..+/,
message: 'please enter a valid email address'
},
...validation
}}
error={error}
{...rest}
/>
)
const EmailField = ({ css, name, validation, required, ...rest }) => {
const { errors, register } = useFormContext()
const ref = validation ? register(validation) : register
const error = errors[name]?.message
return (
<FieldWrapper css={css} label="Email address" fieldId={name}>
<Input
name={name}
id={name}
type="email"
autoComplete="email"
ref={register?.({
required: 'email address is required',
pattern: {
value: /[email protected]+\..+/,
message: 'please enter a valid email address'
},
...validation
})}
error={error}
{...rest}
/>
{error && <InlineMessage css={{ mt: '$1' }}>{error}</InlineMessage>}
</FieldWrapper>
)
}

Accessing form data outside of fields

The same object that useFormContext returns is available in an optional render prop function, making it easy to use the current form state to determine whether a button should be disabled or read the current value of a specific field. Note that if render is provided, Form should not be given any children. An error will be thrown if both are provided.

<Form
onSubmit={console.log}
render={({ formState }) => (
<>
<InputField
label="Name"
name="name"
validation={{ required: 'This field is required!' }}
/>
<Button type="submit" disabled={!formState.isValid}>
Submit
</Button>
</>
)}
/>

To access field value, another render prop that can be used is watch(fieldName: string). If there were no default values provided for the form, on the first render, the watch function will return undefined.

<Form
onSubmit={console.log}
render={({ watch }) => {
const currentFieldValue = watch('name')
return (
<>
<InputField
label="Name"
name="name"
validation={{ required: 'This field is required!' }}
/>
<Button type="submit" disabled={!formState.isValid}>
Submit
</Button>
</>
)
}}
/>

You can also name your render function:

const SomeFormContent = ({ formState }) => (
<>
<InputField
label="Name"
name="name"
validation={{ required: 'This field is required!' }}
/>
<Button type="submit" disabled={!formState.isValid}>
Submit
</Button>
</>
)
const SomeForm = () => <Form onSubmit={console.log} render={SomeFormContent} />

Dynamic fields - useFieldArray()

React-hook-form docs v6

To work with dynamically created fields Form exposes another render prop, useFieldArray. It exposes the following methods and objects:

  • fields: fields is an object keeping all the dynamic fields stored in the field array. Each entry in the field array can have multiple values (e.g { field: 'test', field2: true, field3: [ 'item1', 'item2' ] }, for example for input, checkbox and select)
  • append and prepend are used to insert dynamic fields at the start and end of the array
  • remove and insert add or remove items at specified index
  • move and swap are used to reposition items in the array

There are a few rules that need to be followed to make the dynamic fields work correctly in the context of the form:

  • For the mapping to work correctly, instead of using the index from a map() or some other id, it is required to pass ids generated by the useFieldArray hook.
  • Each dynamic field needs to take a default value from the fields object
  • Each field name needs to be in a specific format fieldArrayName[index].fieldName, where index is the second arg of map function
  • To initialize the useFieldArray hook a control object either from useFormContext or a Form render prop needs to be used.
const { fields } = useFieldArray({
control,
name: 'testArray'
})
//////
{
fields.map((field, index) => (
<CheckboxField
defaultChecked={field.checkbox}
label="Label"
name={`testArray[${index}].field2`}
key={field.id}
/>
))
}

Example:

<Form
defaultValues={{
testArray: [
{ field1: 'test', field2: true },
{ field1: 'test2', field2: true }
]
}}
render={({ control }) => {
const { fields, append, remove } = useFieldArray({
control,
name: 'testArray'
})
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<InputField
label="Input"
name={`testArray[${index}].field1`}
defaultValue={field.field1}
/>
<CheckboxField
label="Checkbox"
name={`testArray[${index}].field2`}
defaultChecked={field.field2}
/>
</div>
))}
<button onClick={() => append({ field1: 'test', field2: false })}>
Add Item
</button>
<button onClick={() => remove(0)}>Remove first item</button>
</>
)
}}
/>

API Reference

PropTypeDefaultRequired
cssCSSProperties

-

-

defaultValues
{ [key: string]: string | number; }
{}

-

validationMode
"all" | "onBlur" | "onChange" | "onSubmit" | "onTouched"
onBlur

-

persist
PersistOptions

-

-

render
(methods: UseFormMethods<FieldValues>) => ReactNode

-

-