Skip to content

Forms Pattern

Form handling with React Hook Form and Yup.

Basic Form

const schema = yup.object({
  title: yup.string().required().max(200),
  amount: yup.number().required().positive(),
  date: yup.date().required(),
});

type FormData = yup.InferType<typeof schema>;

const MyForm = () => {
  const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: yupResolver(schema),
    defaultValues: { title: "", amount: 0, date: new Date() },
  });

  const onSubmit = (data: FormData) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="title"
        control={control}
        render={({ field }) => (
          <TextField
            {...field}
            error={!!errors.title}
            helperText={errors.title?.message}
          />
        )}
      />
    </form>
  );
};

With Mutation

const CreateMarkerForm = () => {
  const { t } = useTranslation();
  const mutation = useCreateMarker();
  const { control, handleSubmit, reset } = useForm<FormData>({
    resolver: yupResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    mutation.mutate(data, {
      onSuccess: () => {
        reset();
        showSuccessToast(t(TranslationKeys.markers.created));
      },
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* fields */}
      <Button type="submit" loading={mutation.isPending}>
        {t(TranslationKeys.shared.button.save)}
      </Button>
    </form>
  );
};

Validation Patterns

const schema = yup.object({
  // Required
  name: yup.string().required(),

  // Optional
  description: yup.string().optional(),

  // Conditional
  endDate: yup.date().when("hasEndDate", {
    is: true,
    then: (schema) => schema.required(),
  }),

  // Custom
  email: yup.string().email().test(
    "not-blocked",
    "This email domain is not allowed",
    (value) => !BLOCKED_DOMAINS.includes(getDomain(value))
  ),
});

i18n in Validation

const schema = yup.object({
  title: yup
    .string()
    .required(t(TranslationKeys.validation.required))
    .max(200, t(TranslationKeys.validation.maxLength, { max: 200 })),
});

FormModal Component

Generic modal wrapper for entity create/edit forms. Handles form setup, dirty tracking, and unsaved changes.

const createSchema = (t: TranslationFunction) => yup.object({
  name: yup.string().required(t(TranslationKeys.validation.required)),
});

const defaultValues = { name: "" };

const mapEntityToForm = (entity: Category) => ({
  name: entity.name,
});

<FormModal<FormData, Category>
  open={isOpen}
  onClose={onClose}
  onSubmit={handleSubmit}
  createSchema={createSchema}
  defaultValues={defaultValues}
  entity={editingEntity}
  mapEntityToForm={mapEntityToForm}
>
  {({ control, errors, isEditMode }) => (
    <>
      <Controller name="name" control={control} render={...} />
      <FormErrorAnnouncer errors={errors} />
    </>
  )}
</FormModal>

Props: - createSchema — Function receiving t translator, returns Yup schema - defaultValues — Default form values (must be stable reference via useMemo or defined outside component) - entity — Entity for edit mode (null for create mode) - mapEntityToForm — Maps entity to form values for edit mode

Features: - Automatic form reset when modal opens - Dirty state tracking - Unsaved changes confirmation on close - Edit mode detection via isEditMode render prop