Blog

React Native Next Level Form Handling

June 28, 2023 10 min read

Advanced form handling techniques in React Native. Learn about validation, performance optimization, and creating reusable form components.

React Native Next Level Form Handling
React NativeFormsValidationPerformanceReact Hook FormZod

React Native Next Level Form Handling

Enhance Form Handling in React Native using Zod and React Hook Form

Form handling is a fundamental part of any application that requires user input, and with the right tools and libraries, it can be a simple and seamless process. This article will guide you on how to handle forms in a react-native app using the react-hook-form and zod. By combining these two, we will achieve an efficient form handling mechanism. Let’s create a basic signup form to illustrate this.

Full source code will be included at the end of the article.

Schema Declaration with Zod

To begin with, we declare our schema using zod. This schema will lay out the expected shape of our form data and set up validations for each field. Zod provides a host of powerful validation functions to define these rules.

Here’s the full zod schema:

This article assumes that you know the basics of zod.

import z from "zod";

const phoneNumberRegexp = new RegExp(
  /^[\+]?([0-9][\s]?|[0-9]?)([(][0-9]{3}[)][\s]?|[0-9]{3}[-\s\.]?)[0-9]{3}[-\s\.]?[0-9]{4,6}$/im
);
const FIELD_REQUIRED_STR = "This field is required";
export const GENDER_OPTIONS = ["Male", "Female", "Other"] as const;

export const signUpFormSchema = z.object({
  name: z
    .string({
      invalid_type_error: "Name must be a string",
      required_error: FIELD_REQUIRED_STR,
    })
    .min(3, "Minimum 3 characters")
    .max(20, "Maximum 20 characters")
    .trim(),

  surname: z
    .string({
      invalid_type_error: "Surname must be a string",
      required_error: FIELD_REQUIRED_STR,
    })
    .min(3, "Minimum 3 characters")
    .max(20, "Maximum 20 characters")
    .trim(),

  phoneNumber: z
    .string({
      invalid_type_error: "Phone number must be a number",
      required_error: FIELD_REQUIRED_STR,
    })
    .regex(phoneNumberRegexp, "Invalid phone number"),

  email: z
    .string({
      invalid_type_error: "Email must be a string",
      required_error: FIELD_REQUIRED_STR,
    })
    .email("Email is invalid"),

  gender: z.enum(GENDER_OPTIONS, {
    required_error: FIELD_REQUIRED_STR,
    invalid_type_error: `Invalid gender, must be one of the followings: ${GENDER_OPTIONS.join(
      ", "
    )}`,
  }),

  birthDate: z
    .date({
      invalid_type_error: "Birth date must be a date",
      required_error: FIELD_REQUIRED_STR,
    })
    .min(new Date("1900-01-01"), "Invalid date time, too old")
    .max(new Date("2023-01-01"), "Invalid date time, too young")
    .default(new Date()),
});

export type SignUpFormSchema = z.infer<typeof signUpFormSchema>;

In this schema, we use zod to specify what each field should contain. For example, the name and surname fields should be strings between 3 to 20 characters long, while the phoneNumber field should be a number.

We also have more complex rules for some fields. For instance, the email field should be a string, and valid email addresses only. The gender field should be one of the predefined gender options ('Male', 'Female', or 'Other').

Lastly, the birthDate field is of date type and should be a valid date between 1900-01-01 and 2023-01-01. By default, it takes the current date if none is provided.

With zod, we can create complex validation rules with ease. This way, the data we receive from our form will be guaranteed to match what we expect.

The next step is to infer the schema type. Zod offers an infer method, which derives the TypeScript type of a given zod schema.

export type SignUpFormSchema = z.infer<typeof signUpFormSchema>;

This inferred type is later on used in useForm to ensure that our form data aligns with our schema.

Form Setup with React Hook Form

Next, we set up our form using the useForm hook from 'react-hook-form'. This hook takes our zod schema (through zodResolver) as an argument to resolve form validation.

const methods = useForm<SignUpFormSchema>({
  resolver: zodResolver(signUpFormSchema),
  defaultValues: {
    birthDate: new Date(),
  },
  mode: "onBlur",
});

The resolver is where we pass our schema. The defaultValues property allows us to provide default values for form fields. In this case, we're setting the default birthDate to be the current date. The mode property dictates when validation should occur. In this case, validation will happen onBlur, or when the user moves away from a field.

Field Rendering and Validation

We use the Controller component from react-hook-form to manage each form field. It provides us with the field and fieldState props which we use to manage our field's value, error state, and validation.

For example, here’s how we set up the name field:

<Controller
  control={methods.control}
  name="name"
  render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => {
    return (
      <TextInput
        label="Name"
        onBlur={onBlur}
        value={value}
        onChangeText={onChange}
        errorMessage={error?.message}
      />
    );
  }}
/>

Here is what each prop does:

  • control: This is where you pass the control prop from the useForm hook. It's necessary for the Controller to interact with the form state.

  • name: This is the key for accessing the value of the input within the form. When you retrieve form values or set values, this is the key that will be used. This must be unique across all form fields.

  • render: This is a function that returns your input element. The function provides field and fieldState props.

  • field: Contains onChange, onBlur, and value props. These should be directly linked to your controlled component. In this case, onChange is connected to onChangeText of TextInput, which will capture the user's input and store it in the form state.

  • fieldState: This includes the error prop that gives information about validation errors for this field.

Inside the render prop, the TextInput is customized. onBlur and onChangeText are attached to the respective methods from the field, and value prop is linked to the current form value for this input. The errorMessage prop is wired to the error message in case of a validation failure.

So, in the case of form submission or real-time validation, if the ‘name’ field doesn’t fulfill the validation requirements defined in the Zod schema, the error message will be displayed in this TextInput field.

Custom Validation Triggering with React Hook Form

A unique scenario arises when dealing with the birthDate field, which uses a date picker component (in this case RNDateTimePicker). In this situation, the validation process requires special handling because the user's interaction with a date picker differs from typical text inputs.

<Controller
  control={methods.control}
  name="birthDate"
  render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => {
    return (
      <View>
        <Text>Birth date</Text>
        <RNDateTimePicker
          value={value}
          mode="date"
          onChange={(e) => {
            if (e.type === "set" && e.nativeEvent.timestamp) {
              onChange(new Date(e.nativeEvent.timestamp));
              methods.trigger("birthDate");
            }
          }}
        />
        {!!error?.message && <Text>{error.message}</Text>}
      </View>
    );
  }}
/>

When the user selects a date, the onChange event is triggered. Inside this event handler, we first check if the event type is 'set', which indicates that the user has set a date. Next, we check if a timestamp is present, to ensure a date has been selected.

Upon confirming these conditions, we convert the timestamp to a JavaScript Date object using new Date(e.nativeEvent.timestamp). Then, this new value is passed to onChange, which updates the birthDate field's value in the form.

However, unlike typical text inputs that automatically trigger validation on changes, date pickers don’t trigger validation in the same way. Therefore, we need to manually trigger validation after the value is updated. We achieve this by calling methods.trigger('birthDate').

This trigger method provided by react-hook-form initiates validation for a specific field. Here, it triggers validation for the birthDate field as soon as the date is set, ensuring that our validation rules are applied immediately.

Finally, if there are any validation errors, they will be displayed via {!!error?.message && {error.message}}, where error?.message contains the validation error message as defined in our zod schema.

Form Submission — React Hook Form

React-hook-form package provides efficient methods to handle form submissions and error handling.

const onSubmit: SubmitHandler<SignUpFormSchema> = (data) => {
  console.log(JSON.stringify(data));
};

const onError: SubmitErrorHandler<SignUpFormSchema> = (errors, e) => {
  console.log(JSON.stringify(errors));
};

<Button
  onPress={methods.handleSubmit(onSubmit, onError)}
  title="Submit Form"
  color={"#007BFF"}
/>;

Let’s break down the process:

The onSubmit handler receives the form data as its argument. In this scenario, we're merely logging this data, but in real-world applications, it could be sent to a server or manipulated further based on the needs of the application.

The onError function, however, is invoked when there are validation errors upon form submission. The errors object contains detailed information about all fields that failed validation. In this example, we're simply logging these errors.

In case of every field has an error, then the error object would be the following:

    {
      "name": {
        "message": "This field is required",
        "type": "invalid_type",
        "ref": {
          "name": "name"
        }
      },
      "surname": {
        "message": "This field is required",
        "type": "invalid_type",
        "ref": {
          "name": "surname"
        }
      },
      "phoneNumber": {
        "message": "This field is required",
        "type": "invalid_type",
        "ref": {
          "name": "phoneNumber"
        }
      },
      "email": {
        "message": "This field is required",
        "type": "invalid_type",
        "ref": {
          "name": "email"
        }
      },
      "gender": {
        "message": "This field is required",
        "type": "invalid_type",
        "ref": {
          "name": "gender"
        }
      },
      "birthDate": {
        "message": "Invalid date time, too young",
        "type": "too_big",
        "ref": {
          "name": "birthDate"
        }
      }
    }

Pre-validation with Zod

The provided code block below presents an approach of manually pre-validating form data using Zod’s safeParse method before the form submission event. Although the form validation is already taken care of by the handleSubmit method from React Hook Form, pre-validation can give developers more control over the form validation process.

<Button
  title="Submit Form"
  onPress={() => {
    // get current form values
    const currFormValues = methods.getValues();

    // Prevalidate using zod's safeParse
    const result = signUpFormSchema.safeParse(currFormValues);

    // If prevalidation is failed, display the error
    if (!result.success) {
      Alert.alert(JSON.stringify(result.error));
    } else {
      // If prevalidation is successful, notify the user
      Alert.alert("Validation is successful with zod");
    }
  }}
/>

Let’s analyze the code above:

  • The onPress event for the Button is being used to execute a function that performs pre-validation before form submission.

  • The getValues method from methods (which is provided by React Hook Form) is used to retrieve the current form values.

  • These values are then passed to the safeParse method from the signUpFormSchema (a zod schema that we created in the beginning). This step is where zod pre-validation happens. Unlike zod parse method, safeParse does not throw an error when the validation fails. Instead, it returns an object containing the success boolean and, if the validation failed, an error object.

  • A conditional statement checks if result.success is false (indicating that the validation failed). If it's false, it triggers an alert displaying the validation error.

  • If result.success is true (indicating successful validation), it triggers an alert stating that the validation was successful with zod.

Even though this approach might seem redundant because React Hook Form’s handleSubmit already performs the form validation using the schema provided via zod, it can still be beneficial in specific scenarios. For instance, you might want to perform certain actions only after manual validation, or you might want to validate form inputs in real-time as the user fills out the form. In such cases, pre-validation using zod safeParse method can be quite useful.

And this is how zod error object would look like:

    {
      "issues": [
        {
          "code": "invalid_type",
          "expected": "string",
          "received": "undefined",
          "path": [
            "name"
          ],
          "message": "This field is required"
        },
        {
          "code": "invalid_type",
          "expected": "string",
          "received": "undefined",
          "path": [
            "surname"
          ],
          "message": "This field is required"
        },
        {
          "code": "invalid_type",
          "expected": "string",
          "received": "undefined",
          "path": [
            "phoneNumber"
          ],
          "message": "This field is required"
        },
        {
          "code": "invalid_type",
          "expected": "string",
          "received": "undefined",
          "path": [
            "email"
          ],
          "message": "This field is required"
        },
        {
          "expected": "'Male' | 'Female' | 'Other'",
          "received": "undefined",
          "code": "invalid_type",
          "path": [
            "gender"
          ],
          "message": "This field is required"
        },
        {
          "code": "too_big",
          "message": "Invalid date time, too young",
          "inclusive": true,
          "exact": false,
          "maximum": 1672531200000,
          "type": "date",
          "path": [
            "birthDate"
          ]
        }
      ],
      "name": "ZodError"
    }

Its indeed a large object, and there might be even unnecessary fields for our case. Luckily, zod provides a method for formatting errors.

We can simply use result.error.format() and the rest will be handled internally by zod.

if (!result.success) {
  // Format errors using result.error.format()
  const formattedError = result.error.format();
  Alert.alert(JSON.stringify(formattedError));
} else {
  Alert.alert("Validation is successful with zod");
}

Formatted error object would like the following:

    {
      "name": {
        "_errors": [
          "This field is required"
        ]
      },
      "surname": {
        "_errors": [
          "This field is required"
        ]
      },
      "phoneNumber": {
        "_errors": [
          "This field is required"
        ]
      },
      "email": {
        "_errors": [
          "This field is required"
        ]
      },
      "gender": {
        "_errors": [
          "This field is required"
        ]
      },
      "birthDate": {
        "_errors": [
          "Invalid date time, too young"
        ]
      }
    }

The latest code

The following is the most recent code (excluding imports and styles). If you wish to view the complete source code, please refer to the end of the article.

// imports...

export const SignupForm: React.FC = () => {
  const [shouldValidateWithZod, setShouldValidateWithZod] =
    React.useState<boolean>(false);

  const [isCalendarOpenForAndroid, setIsCalendarOpenForAndroid] =
    React.useState<boolean>(false);

  const methods = useForm<SignUpFormSchema>({
    resolver: zodResolver(signUpFormSchema),
    defaultValues: {
      birthDate: new Date(),
    },
    mode: "onBlur",
  });

  const onSubmit: SubmitHandler<SignUpFormSchema> = (data) => {
    console.log(JSON.stringify(data));
  };

  const onError: SubmitErrorHandler<SignUpFormSchema> = (errors, e) => {
    console.log(JSON.stringify(errors));
    Alert.alert("Warning", getReadableValidationErrorMessage(errors));
  };

  return (
    <SafeAreaView edges={["top", "bottom"]} style={styles.safeArea}>
      <ScrollView>
        <Text style={styles.title}>Sign Up Form</Text>
        <View style={styles.root}>
          <FormProvider {...methods}>
            <Controller
              control={methods.control}
              name="name"
              render={({
                field: { onChange, onBlur, value },
                fieldState: { error },
              }) => {
                return (
                  <TextInput
                    label="Name"
                    onBlur={onBlur}
                    value={value}
                    onChangeText={onChange}
                    errorMessage={error?.message}
                  />
                );
              }}
            />
            <View style={styles.spacing} />
            <Controller
              control={methods.control}
              name="surname"
              render={({
                field: { onChange, onBlur, value },
                fieldState: { error },
              }) => {
                return (
                  <TextInput
                    label="Surname"
                    onBlur={onBlur}
                    value={value}
                    onChangeText={onChange}
                    errorMessage={error?.message}
                  />
                );
              }}
            />
            <View style={styles.spacing} />

            <Controller
              control={methods.control}
              name="email"
              render={({
                field: { onChange, onBlur, value },
                fieldState: { error },
              }) => {
                return (
                  <TextInput
                    label="Email"
                    onBlur={onBlur}
                    value={value}
                    onChangeText={onChange}
                    errorMessage={error?.message}
                  />
                );
              }}
            />

            <View style={styles.spacing} />

            <Controller
              control={methods.control}
              name="phoneNumber"
              render={({
                field: { onChange, onBlur, value },
                fieldState: { error },
              }) => {
                return (
                  <TextInput
                    label="Phone number"
                    onBlur={onBlur}
                    keyboardType="decimal-pad"
                    value={value}
                    onChangeText={(val) => onChange(val.toString())}
                    errorMessage={error?.message}
                  />
                );
              }}
            />

            <View style={styles.spacing} />

            <Controller
              control={methods.control}
              name="birthDate"
              render={({
                field: { onChange, value },
                fieldState: { error },
              }) => {
                return (
                  <View style={styles.dateWrapper}>
                    <Text style={styles.label}>Birth date</Text>

                    {Platform.OS === "android" && (
                      <Button
                        onPress={() => {
                          setIsCalendarOpenForAndroid(true);
                        }}
                        title={value.toString()}
                      />
                    )}

                    {(isCalendarOpenForAndroid ||
                      Platform.OS !== "android") && (
                      <RNDateTimePicker
                        value={value}
                        style={styles.dateTimePicker}
                        mode={"date"}
                        onChange={(e) => {
                          if (Platform.OS === "android") {
                            setIsCalendarOpenForAndroid(false);
                          }

                          if (e.type === "set" && e.nativeEvent.timestamp) {
                            onChange(new Date(e.nativeEvent.timestamp));
                            methods.trigger("birthDate");
                          }
                        }}
                      />
                    )}

                    {!!error?.message && (
                      <Text style={styles.errorMessageText}>
                        {error.message}
                      </Text>
                    )}
                  </View>
                );
              }}
            />

            <View style={styles.spacing} />

            <Controller
              control={methods.control}
              name="gender"
              render={({
                field: { onChange, onBlur, value, ref },
                fieldState: { error },
              }) => {
                return (
                  <View style={styles.genderWrapper}>
                    <Text style={styles.label}>Gender</Text>
                    <View style={styles.genderOptionsWrapper}>
                      {GENDER_OPTIONS.map((genderOption) => (
                        <Pressable
                          key={genderOption}
                          style={[
                            styles.genderOptionPressable,
                            {
                              backgroundColor:
                                value === genderOption ? "#007BFF" : "#F2F2F2",
                            },
                          ]}
                          onPress={() => onChange(genderOption)}
                        >
                          <Text
                            style={[
                              styles.genderOptionText,
                              {
                                color: value === genderOption ? "#fff" : "#000",
                              },
                            ]}
                          >
                            {genderOption}
                          </Text>
                        </Pressable>
                      ))}
                    </View>
                    {!!error?.message && (
                      <Text style={styles.errorMessageText}>
                        {error.message}
                      </Text>
                    )}
                  </View>
                );
              }}
            />

            <View style={styles.spacing} />

            <Text style={styles.label}>Should validate with zod</Text>

            <Switch
              value={shouldValidateWithZod}
              onChange={(e) => setShouldValidateWithZod(e.nativeEvent.value)}
            />
            <View style={styles.spacing} />

            <Button
              onPress={
                shouldValidateWithZod
                  ? () => {
                      /** we could also prevalidate using zod like below */

                      // get current form values
                      const currFormValues = methods.getValues();
                      // https://zod.dev/?id=safeparse
                      const result = signUpFormSchema.safeParse(currFormValues);

                      if (!result.success) {
                        const formattedError = result.error.format();
                        Alert.alert(JSON.stringify(formattedError));
                      } else {
                        Alert.alert("Validation is successful with zod");
                      }
                    }
                  : methods.handleSubmit(onSubmit, onError)
              }
              title="Submit Form"
              color={"#007BFF"}
            />
          </FormProvider>
        </View>
      </ScrollView>
    </SafeAreaView>
  );
};

// styles...

This sample app is written using expo go. Meaning you can easily test it using the QR code below! (in case the QR does not work, please refer to the source code link at the end of the article.)

Conclusion

Throughout this article, we have explored how we can effectively leverage the combination of React Hook Form, Zod, and TypeScript to create robust form validation in our React Native application. We’ve seen how the flexibility and power of these libraries enable us to produce clean, type-safe code that can handle complex validation requirements.

The synergy of React Hook Form and Zod allows us to reap the benefits of both. React Hook Form simplifies form handling, optimizes re-rendering, and includes features such as form state management and error handling. On the other hand, Zod provides an extra layer of safety, allowing us to specify what kind of data is allowed for each form field and to enforce these rules.

Remember, while the techniques shown here are used within the context of a simple SignUp form, they are applicable to any form-based user input in your React Native applications. By employing these strategies, you can ensure your forms are user-friendly, robust, and maintainable, leading to better user experiences and more robust applications.

Full source code link