Forms
Code examples of how to build forms with EDS.Forms are the most common interaction pattern in functional apps, and there are many ways to build them in React but the fundamental idea is to control the form input values with React state.
Most forms also need validation, which is to check that the form input values are valid before they are submitted.
The following examples show how to build a simple form using various techniques and library integrations.
Controlling the form input values with useState is the simplest way to build a form in React. Simple validation can be done by checking the value of the input in the onSubmit handler. If the value is invalid, the handler can prevent the form from being submitted.
const [value, setValue] = useState(''); const [error, setError] = useState<string>(); const [submittedValue, setSubmittedValue] = useState<string>(); const validate = (value: string) => { if (value === '') { setError('Please enter your name'); return false; } if (value.length < 3) { setError('Name must be at least 3 characters'); return false; } return true; }; const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { // Prevent default browser behaviour of refreshing the page e.preventDefault(); if (!validate(value)) { return; } setSubmittedValue(value); setError(''); }; return ( <Flex as="form" onSubmit={handleSubmit} maxWidth="30rem" gap="medium" flexDirection="column"> <Fieldset legend="Employee details" legendStyle="group"> <Box marginBottom="large"> <Text fontSize="small" aria-hidden> The fields marked with a <Text color="critical">*</Text> must be filled out before submitting this form. </Text> </Box> <Field description="Must be at least 3 characters" invalid={!!error} label="Name" message={error ?? undefined} required keepSpaceForMessage > <TextInput value={value} onChange={(e) => { setValue(e.target.value); }} /> </Field> <Box marginTop="medium"> <Button type="submit" label="Submit" /> </Box> {submittedValue && <ResponseBlock prefix="Submitted with value:" value={submittedValue} />} </Fieldset> </Flex> );
Using a form library like React Hook Form makes it easier to build forms. It provides a hook called useForm with a set of methods to manage the state and validation. EDS components are built to integrate well with this type of libraries.
React Hook Form provides two ways to hook up EDS components:
- The register function can be used to register components that support being uncontrolled such as the <TextInput /> component.
- The <Controller /> component can be used to register components that only support being controlled.
type InputTypes = { name: string; country: { label: string; value: string; }; }; const [submittedValue, setSubmittedValue] = useState(''); const { register, handleSubmit, formState: { errors }, control, } = useForm<InputTypes>(); const onSubmit: SubmitHandler<InputTypes> = (values) => { setSubmittedValue(JSON.stringify(values)); }; return ( <Box as="form" maxWidth="30rem" onSubmit={handleSubmit(onSubmit)}> <Fieldset legend="Employee details" legendStyle="group"> <Box marginBottom="large"> <Text fontSize="small" aria-hidden> The fields marked with a <Text color="critical">*</Text> must be filled out before submitting this form. </Text> </Box> <Field description="Must be at least 3 characters" invalid={!!errors.name} keepSpaceForMessage label="Name" message={errors?.name?.message} required > <TextInput placeholder="Enter your name" {...register('name', { required: 'Please enter your name', minLength: { value: 3, message: 'Name must be at least 3 characters', }, })} /> </Field> <Field invalid={!!errors.country} keepSpaceForMessage label="Country" message={errors?.country?.message} required> <Controller name="country" control={control} rules={{ required: 'Please select a country' }} render={({ field }) => { const { onChange, value } = field; return ( <SelectInput items={[ { label: 'Australia', value: 'au' }, { label: 'Canada', value: 'ca' }, { label: 'New Zealand', value: 'nz' }, { label: 'United States of America', value: 'us' }, { label: 'Sweden', value: 'se' }, ]} onChange={onChange} value={value} /> ); }} /> </Field> <Box marginTop="medium"> <Button type="submit" label="Submit" /> </Box> {submittedValue && <ResponseBlock prefix="Submitted with value:" value={submittedValue} />} </Fieldset> </Box> );
To help manage validation and form state, using a validation library like Yup with React Hook Form is highly recommended.
Use the mode: 'onBlur' setting to trigger instant validation when the user blurs (leaves) each input.
const [files, setFiles] = useState<InputFile[]>([]); const [submittedValue, setSubmittedValue] = useState(''); type InputTypes = { name: string; country: { label: string; value: string }; resume: InputFile[]; }; const schema: yup.ObjectSchema<InputTypes> = yup.object({ name: yup.string().required('Please enter your name').min(3, 'Name must be at least 3 characters.'), country: yup .object() .shape({ label: yup.string().default(undefined).required('Please select a country'), value: yup.string().default(undefined).required('Please select a country.'), }) .nullable() .required('Please select a country.'), resume: yup.array().min(1, 'Please select a file.').required(), }); const { register, handleSubmit, formState: { errors }, control, setValue, setError, getFieldState, } = useForm<InputTypes>({ resolver: yupResolver(schema), mode: 'onBlur', defaultValues: { name: '', country: undefined, resume: [], }, }); const onSubmit = (values: InputTypes) => { setSubmittedValue(JSON.stringify(values)); }; /** * Manually treat the `resume` field due to the FileInput's `onChange` prop's asynchronous logic * and incompatibility with react-hook-form's `onChange` handler. */ useEffect(() => { // Check if any file has 'failed' status const hasFailedFile = files.some((file) => file.status === 'failed'); if (hasFailedFile) { // Set an error on `resume` field setError('resume', { type: 'manual', message: 'Upload failed for one or more files.', }); } else { if (files.length === 0 && !getFieldState('resume').isDirty) { // Set initial `resume` field value to react-hook-form state without causing validation on initial mount setValue('resume', files); } else { // If field is dirty (has been changed by user) then it should validate (which causes re-render) setValue('resume', files, { shouldValidate: true, shouldDirty: true }); } } }, [files, getFieldState, setError, setValue]); return ( <Box as="form" onSubmit={handleSubmit(onSubmit)} maxWidth="30rem"> <Fieldset legend="Employee details" legendStyle="group"> <Box marginBottom="large"> <Text fontSize="small" aria-hidden> The fields marked with a <Text color="critical">*</Text> must be filled out before submitting this form. </Text> </Box> <Field description="Must be at least 3 characters" invalid={!!errors.name} keepSpaceForMessage label="Name" message={errors?.name?.message} required > <TextInput placeholder="Enter your name" {...register('name')} /> </Field> <Field keepSpaceForMessage label="Country" invalid={!!errors.country} message={errors?.country?.message ?? errors?.country?.value?.message} required > <Controller name="country" control={control} render={({ field }) => { const { onChange, onBlur, value } = field; return ( <SelectInput items={[ { label: 'Australia', value: 'au' }, { label: 'Canada', value: 'ca' }, { label: 'New Zealand', value: 'nz' }, { label: 'United States of America', value: 'us' }, { label: 'Sweden', value: 'se' }, ]} onChange={onChange} onBlur={onBlur} value={value} /> ); }} /> </Field> <Field keepSpaceForMessage label="Resume" invalid={!!errors.resume} message={errors?.resume?.message} required> <Controller name="resume" control={control} render={({ field }) => { const { onBlur } = field; return ( <FileInput maxFileCount={1} value={files} onChange={setFiles} onBlur={onBlur} onDrop={async (newFile: InputFile[]) => { await new Promise((resolve) => setTimeout(resolve, 3000)); return newFile.map( (file) => ({ ...file, url: 'new-remote-url', status: FileStatus.UPLOADED, } as InputFile) ); }} onDelete={async (fileToDelete: InputFile) => { await new Promise((resolve) => { setTimeout(resolve, 3000); }); setFiles((files) => files.filter((file) => file.id !== fileToDelete.id)); console.log('Deleted: ', fileToDelete); }} /> ); }} /> </Field> <Box marginTop="medium"> <Button type="submit" label="Submit" /> </Box> {submittedValue && <ResponseBlock prefix="Submitted with value:" value={submittedValue} />} </Fieldset> </Box> );
const [submittedValue, setSubmittedValue] = useState(''); const validationSchema = yup.object({ name: yup.string().required('Please enter your name.').min(3, 'Name must be at least 3 characters.'), termsAndConditions: yup.boolean().required().oneOf([true], 'Please accept the terms and conditions.'), phone: yup.string().required('Please enter your phone number.'), country: yup .object() .shape({ label: yup.string().default(undefined).required('Please select a country'), value: yup.string().default(undefined).required('Please select a country.'), }) .nullable() .required('Please select a country.'), notes: yup.string(), }); type InputTypes = yup.InferType<typeof validationSchema>; const { register, handleSubmit, control, formState: { errors }, } = useForm<InputTypes>({ resolver: yupResolver(validationSchema), mode: 'onBlur', defaultValues: { name: '', phone: '', country: undefined, notes: '', termsAndConditions: false, }, }); const onSubmit = (values: InputTypes) => { setSubmittedValue(JSON.stringify(values)); }; return ( <Flex as="form" onSubmit={handleSubmit(onSubmit)} flexDirection="column" maxWidth="30rem" gap="large"> <Fieldset legend="Employee details" legendStyle="group"> <Box marginBottom="large"> <Text fontSize="small" aria-hidden> The fields marked with a <Text color="critical">*</Text> must be filled out before submitting this form. </Text> </Box> <Field description="Must be at least 3 characters" invalid={!!errors.name} keepSpaceForMessage label="Name" message={errors?.name?.message} required > <TextInput placeholder="Enter your name" {...register('name')} /> </Field> <Field label="Phone" required message={errors?.phone?.message} invalid={!!errors.phone}> <Controller name="phone" control={control} render={({ field }) => { const { onChange, onBlur, value } = field; return <PhoneInput value={value ?? ''} onChange={onChange} onBlur={onBlur} />; }} /> </Field> <Field label="Country" invalid={!!errors.country} message={errors?.country?.message ?? errors?.country?.value?.message} required > <Controller name="country" control={control} render={({ field }) => { const { onChange, onBlur, value } = field; return ( <SelectInput items={[ { label: 'Australia', value: 'au' }, { label: 'Canada', value: 'ca' }, { label: 'New Zealand', value: 'nz' }, { label: 'United States of America', value: 'us' }, { label: 'Sweden', value: 'se' }, ]} onChange={onChange} onBlur={onBlur} value={value} /> ); }} /> </Field> <Field label="Notes"> <TextAreaInput placeholder="Notes" size="medium" rows={2} {...register('notes')} /> </Field> <Controller name="termsAndConditions" control={control} render={({ field }) => { const { onChange, onBlur, value } = field; return ( <Checkbox checked={value} onChange={onChange} onBlur={onBlur}> Accept terms and conditions </Checkbox> ); }} /> {errors.termsAndConditions && ( <Text color="critical" marginTop="xxsmall" fontSize="xsmall"> {errors.termsAndConditions.message} </Text> )} <Box marginTop="xxlarge"> <Button type="submit" label="Submit" /> </Box> {submittedValue && <ResponseBlock prefix="Submitted with values:" value={submittedValue} />} </Fieldset> </Flex> );