DEV Community

Thomas Coldwell
Thomas Coldwell

Posted on

Dynamic Formik Validation 💡

Formik is one of the most widely used libraries for handling, validating and submitting user forms for React and React Native.

So far it has worked pretty well for most projects I've gone to use it in but then I got stumped the other day. I had a form which featured a section where the user could select a copyright license type from a drop down menu. Based on the license type they selected there would then be a different set of fields they would have to fill in to satisfy the required information for that license such as author, url - you get the picture.

To use Formik you normally just pass some initial values and a validation schema (created with Yup) to the useFormik hook and away you go. However, both the initial values and validation schema would now need to be dynamic and I needed a good way to handle this. Additionally, while dynamically adding and removing a section of the form I needed to make sure to remove now unused field values so they didn't get passed to the output and ensure the current values of the form weren't removed in the process.

Rather than try figure this out directly on the project I was working on I decided to make a separate project with the following form structure. The main form features a field to enter your name which is then followed by a subform. This subform has a dropdown menu where the user can select to enter either their email or email and phone - upon selecting either of these the relevant fields will then be rendered underneath it. The resulting UI looked like this:

Alt Text

Alt Text

The first thing to do was to create an input that would display any errors appropriately from Formik:

interface IInputProps extends TextInputProps {
  error?: string;
  touched?: boolean;
}

export default function Input(props: IInputProps) {
  const { error, touched, ...inputProps } = props;
  return (
    <View style={{ width: "100%", marginBottom: 10 }}>
      <TextInput {...inputProps} />
      {!!error && !!touched && (
        <Text style={{ color: "#f00" }}>{"* " + error}</Text>
      )}
    </View>
  );
}

This simply shows the input and whatever Formik errors there are if any as red text under the input. It will also only show any of these errors if the field has been touched and lost focus from the user (e.g. touched another field or dismissed keyboard).

The main form can then be created as follows too noting OtherForm as the subform I mentioned with the dropdown options:

export const mainFormInitialValues = {
  name: "",
  subForm: {},
};

export default function App() {
  // The output of the form
  const [result, setResult] = React.useState({});

  const [initialValues, setInitialValues] = React.useState<FormValues>(
    mainFormInitialValues
  );
  const [validation, setValidation] = React.useState(formValidation);

  // Just save the output of the form to be
  const onSubmit = (values: FormValues) => setResult(values);

  // Define the formik hook
  const formik = useFormik({
    initialValues,
    validationSchema: validation,
    onSubmit: (values) => onSubmit(values),
    validateOnBlur: true,
  });

  // Destructure the formik bag
  const {
    values,
    errors,
    touched,
    handleChange,
    handleSubmit,
    validateForm,
    handleBlur,
  } = formik;

  // Any time we dynamically change the validation schema revalidate the
  // form
  React.useEffect(() => {
    validateForm();
  }, [validation]);

  // If a dynamic form changes then handle the update of the initial values
  // and validation schema here
  const handleFormChange = (formDetails: FormDetails) => {
    // Set the intitial values and validation schema based on the form change
    setInitialValues({ ...initialValues, ...formDetails.values });
    const newSchema = validation.shape(formDetails.validation);
    setValidation(newSchema);
  };

  return (
    <ScrollView>
      <View style={styles.container}>
        <Input
          style={styles.input}
          placeholder="name"
          onChangeText={handleChange("name")}
          onBlur={handleBlur("name")}
          value={values.name}
          error={errors.name}
          touched={touched.name}
        />
        <OtherForm
          formik={formik}
          onChangeForm={(formDetails: FormDetails) =>
            handleFormChange(formDetails)
          }
        />
        <View style={{ width: "100%", marginBottom: 20 }}>
          <Button onPress={handleSubmit as any} title="Submit" />
        </View>
        <Text style={styles.output}>
          {"Initial Values: " + JSON.stringify(initialValues, null, 2)}
        </Text>
        <Text style={styles.output}>
          {"Live Values: " + JSON.stringify(values, null, 2)}
        </Text>
        <Text style={styles.output}>
          {"Form Output: " + JSON.stringify(result, null, 2)}
        </Text>
      </View>
    </ScrollView>
  );
}

This features the input for the name field, the OtherForm subform, a submit button and 3 debug text boxes to log the initial values being passed to Formik, the current values and the output of the form when onSubmit is triggered. The magic in the main form happens with the handleFormChange function. This gets called as a prop from the subform which passes the new initial values and validation schema up the main form. The state of the initial values and validation schema can then be updated accordingly so that the useFormik hook now has the right arguments being passed to it to support the subform in its new state. Whenever this validation schema changes Formik won't automatically revalidate so there is another useEffect hook that triggers a revalidation if the schema changes.

Finally, there is the logic on the subform side to handle changing the form type and passing this information back to the main form:

interface IOtherFromProps {
  formik: FormikProps<FormValues>;
  onChangeForm: (formDetails: FormDetails) => void;
}

type Fields = "email" | "phone";

const dropDownItems = [
  { label: "Email only", value: "email-only" },
  { label: "Email and Phone", value: "email-and-phone" },
];

type FormType = "email-only" | "email-and-phone";

type TypeFields = {
  [key: string]: Fields[];
};

const typeFields: TypeFields = {
  "email-only": ["email"],
  "email-and-phone": ["email", "phone"],
};

export default function OtherForm({ formik, onChangeForm }: IOtherFromProps) {
  // Setup the form type state selected from the drop down
  const [formType, setFormType] = React.useState<FormType>("email-only");

  // Unpack the formik bag passed from the parent
  const { values, errors, touched, setValues, handleBlur } = formik;

  const handleFormChange = (type: FormType) => {
    // Set required fields to be displayed
    const fields = typeFields[type];
    setFormType(type);
    // Create the values object from the array of required fields
    // re-using previously entered values if present
    const formValues = fields.reduce(
      (obj, item) => ({
        ...obj,
        [item]: values.subForm[item] ? values.subForm[item] : "",
      }),
      {}
    );
    // Create the validation schema to require each of these values
    const formSchema = fields.reduce(
      (obj, item) => ({ ...obj, [item]: Yup.string().required('Required') }),
      {}
    );
    // Set the initial values and validation schema for the form in its new state
    onChangeForm({
      values: {
        subForm: formValues,
      },
      validation: {
        subForm: Yup.object(formSchema),
      },
    });
    // Set the current live values
    setValues({ ...values, subForm: formValues });
  };

  React.useEffect(() => {
    // Set up the initial values and validation schema on first render
    handleFormChange(formType);
  }, []);

  return (
    <View style={styles.subForm}>
      <Picker
        selectedValue={formType}
        style={{
          height: 40,
          width: "100%",
        }}
        onValueChange={(value: any) => handleFormChange(value)}
      >
        {dropDownItems.map((item) => (
          <Picker.Item value={item.value} key={item.value} label={item.label} />
        ))}
      </Picker>
      {!!formType &&
        typeFields[formType].map((field) => (
          <Input
            key={field}
            style={styles.input}
            placeholder={field}
            onChangeText={(text) =>
              setValues({
                ...values,
                subForm: { ...values.subForm, [field]: text },
              })
            }
            value={values.subForm[field]}
            error={errors.subForm && errors.subForm[field]}
            touched={touched.subForm && touched.subForm[field]}
            onBlur={handleBlur("subForm." + field)}
          />
        ))}
    </View>
  );
}

The main thing to dissect here is the handleFormChange function. Whenever the dropdown selection (subform type) is changed this will perform a number of actions. Firstly, it looks up what fields are required (specified in an object called typeFields) and sets the type to some local state so it knows what fields to display. Secondly it creates an object of the values required and their initialised state (normally an empty string but uses a previously store value for that field if there is one) and a Yup validation object with each required field being assigned a Yup required string value. Both the values and validation schema are then passed to callback onChangeForm which is handled in the main form as previously described. Finally, the live values of the form are updated to include the new subform values too.

There is also a useEffect hook that is only triggered on first render that runs the handleFormChange function once with the default form type selection - this ensures the values and validation state are initialised.

Here's it in action:

Alt Text

And that's all there is too it! I hope this helps other people who have maybe been stuck with a similar problem and if anyone has any tips on how this solution could be improved I'd love to hear it!

The full code for this can be accessed below:

https://github.com/thomas-coldwell/Formik-Dynamic-Sub-Form

Happy hacking!

Top comments (0)