EDS4 logo

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>
);