Back

React Hook Form for React Native

Alex Silcox
Alex Silcox
September 9, 2021
React Hook Form for React Native

When looking for a way to integrate forms in one of my more recent React Native projects, I found React Hook Form after it being recommended by my colleagues at Echobind who used it as a solution for React web. It trades itself as a "Performant, flexible, and extensible [solution for] forms with easy-to-use validation". I was excited to find its support for React Native, but as I dove into trying it as a solution, I was quickly confused by its lack of documentation and examples for React Native.

One of the concepts in React Hook Form is the ability to register your uncontrolled component into the hook. The current example utilizes a Controller pattern, wrapping all components on the same level. While this approach is straightforward for simpler applications, it can be cumbersome/problematic for larger apps because it doesn't enable flexibility for nested component structures. There is a solution to this in the form of hooks, but the documentation is only limited to a React context.

This blog post will help guide utilizing React Hook Form to register form inputs on the component level via hooks, making its value available for form validation and submission for its parent components. When I started using React Hook Form for React Native, I based my learning on this article by Daniel Koprowski. Since then I've adapted it to using v7 and Typescript, that's what this guide will be based on. So let's get started!

Getting started

Assuming you have your React Native environment setup and dependencies installed, let's start by adding the following code to match React Hook Form's React Native example (https://react-hook-form.com/get-started/#ReactNative):

// App.tsx import * as React from 'react'; import { Text, View, StyleSheet, TextInput, Button, Alert } from 'react-native'; import { useForm, Controller } from 'react-hook-form'; export default function App() { const { register, setValue, handleSubmit, control, reset, formState: { errors } } = useForm(); const onSubmit = data => { console.log(data); }; const onError: SubmitErrorHandler<FormValues> = (errors, e) => { return console.log(errors) } return ( <View style={styles.container}> <Text style={styles.label}>First name</Text> <Controller control={control} render={({field: { onChange, onBlur, value }}) => ( <TextInput style={styles.input} onBlur={onBlur} onChangeText={value => onChange(value)} value={value} /> )} name="email" rules={{ required: true }} /> <Text style={styles.label}>Last name</Text> <Controller control={control} render={({field: { onChange, onBlur, value }}) => ( <TextInput style={styles.input} onBlur={onBlur} onChangeText={value => onChange(value)} value={value} /> )} name="password" rules={{ required: true }} /> <View style={styles.button}> <Button style={styles.buttonInner} color title="Reset" onPress={() => { reset({ email: 'jane@example.com', password: '****' }) }} /> </View> <View style={styles.button}> <Button style={styles.buttonInner} color title="Button" onPress={handleSubmit(onSubmit)} /> </View> </View> ); }; const styles = StyleSheet.create({ label: { color: 'white', margin: 20, marginLeft: 0, }, button: { marginTop: 40, color: 'white', height: 40, backgroundColor: '#ec5990', borderRadius: 4, }, container: { flex: 1, justifyContent: 'center', paddingTop: Constants.statusBarHeight, padding: 8, backgroundColor: '#0e101c', }, input: { backgroundColor: 'white', borderColor: 'none', height: 40, padding: 10, borderRadius: 4, }, });

While in smaller apps, this pattern could work out just fine, but for more complex applications with multiple or extensive forms, custom/dynamic inputs, etc, this pattern can be cumbersome if a controller needs to be assigned to every TextInput component. So let's make this more efficient by integrating the controller functionality within a custom TextInput component.

Setting up the files

1. Create TextInput component

The first thing we will do is create a custom TextInput component. Right now this component will be pretty basic. It will be a wrapper for the React Native Core TextInput component, with some styling (originally defined in App.tsx) and props such as label and any props inherited from TextInput (renamed RNTextInput since we'll have our new TextInput declared) as inputProps. You might be wondering why we aren't passing props like value, onBlur, andonChangeText? Don't worry we will get to that part later.

// components/TextInput.tsx import React from 'react'; import { View, TextInput as RNTextInput, Icon, Text, StyleSheet } from 'react-native'; interface TextInputProps extends RNTextInputProps { label: string } export const TextInput = (props) => { const { label, ...inputProps } = props; return ( <View style={styles.container}> {label && (<Text style={styles.label}>{label}</Text>)} <View style={styles.inputContainer}> <RNTextInput style={styles.input} {...inputProps} /> </View> </View> );}; const styles = StyleSheet.create({ label: { color: 'white', margin: 20, marginLeft: 0, }, container: { flex: -1, justifyContent: 'center', padding: 8, backgroundColor: '#0e101c', borderColor: 'white', borderWidth: 1 }, input: { backgroundColor: 'white', borderColor: 'none', height: 40, padding: 10, borderRadius: 4, } });

2. Integrating React Hook Form into our TextInput component

This is where the magic begins. There are two main hooks that we will want to import from React Hook Form, useController, and useFormContext. useController hook establishes the instance of our controlled input and stores its value to the form, and the useFormContext hook will allow us to access the form's context, its methods, and state.

For useController to work, name is required to be registered to it. Optionally, you can also pass along any validationrules, and an inputs defaultValue. So let's add those as well to our TextInput props and register them to the controller. Be sure to also extend the TextInput props to inherit the UseControllerProps.

// components/TextInput.tsx import React from 'react'; import { View, TextInput as RNTextInput, TextInputProps as RNTextInputProps, Text, StyleSheet } from 'react-native'; /* IMPORT HOOKS AND PROPS TYPES */ import { useController, useFormContext, ControllerProps, UseControllerProps } from 'react-hook-form'; /* EXTENDING PROPS TYPES TO INHERIT NAME AND RULES FROM USECONTROLLERPROPS */ interface TextInputProps extends RNTextInputProps, UseControllerProps { label: string defaultValue?: string //ADD DEFAULT VALUE TO PROPS } export const TextInput = (props: TextInputProps) => { /* GET THE FORMS CONTEXT */ const formContext = useFormContext(); /* DECONSTRUCT PROPS */ const { label, name, rules, defaultValue, ...inputProps } = props /* REGISTER THE CONTROLLER WITH VALUES RECEIVED THROUGH PROPS */ const { field } = useController({ name, rules, defaultValue });

Again, without a name, the app will throw an error. We can safely type our component to require it. Additionally, we could extend this even further by not rendering the input if name or formContext doesn't exist. I'm generally not a fan of this approach by itself, as it could not render all the inputs in the form and lead to confusing form submission errors if required inputs didn't render. So let's pass a message to the developer to inform when the formContext or name does not exist.

// components/TextInput.tsx export const TextInput = (props: TextInputProps) => { const formContext = useFormContext(); const { name, rules, label, defaultValue ...inputProps } = props; const formContext = useFormContext(); const {...methods} = formContext /* RETURN MESSAGE TO DEVELOPER WHEN FORMCONTEXT OR NAME IS MISSING */ if (!formContext || !name) { const msg = !formContext ? "TextInput must be wrapped by the FormProvider" : "Name must be defined" console.error(msg) return null } const { field } = useController({ name, rules, defaultValue });

Now there's one more problem with this code. useController is being called conditionally, breaking React's rule of hooks. So if we were to go with this method, we need to split out the logic to separate components. Let's relocate our useController hook and rendered components and their props in a new functional component calledControlledInput.

// components/TextInput.tsx > ControlledInput /* MOVE USECONTROLLER HOOK AND RENDERED CONTENT TO CONTROLLEDINPUT WHEN USECONTROLLER IS AVAILABLE */ const ControlledInput = (props: TextInputProps) => { const formContext = useFormContext(); const { formState } = formContext; const { name, label, rules, defaultValue, ...inputProps } = props; const { field } = useController({ name, rules, defaultValue }); return ( <View style={styles.container}> {label && (<Text style={styles.label}>{label}</Text>)} <View style={styles.inputContainer}> <RNTextInput style={styles.input} {...inputProps} /> </View> </View> ); }

Now, this is accessible to our TextInput component and only returns whenformContext and name exist.

// components/ TextInput.tsx > TextInput /* TEXTINPUT COMPONENT ONLY CALLS THE CONTROLLEDINPUT COMPONENT WHEN FORMCONTEXT AND NAME ARE AVAILABLE */ export const TextInput = (props: TextInputProps) => { const { name } = props; const formContext = useFormContext(); if (!formContext || !name) { const msg = !formContext ? "TextInput must be wrapped by the FormProvider" : "Name must be defined" console.error(msg) return null } return <ControlledInput {...props} />; };

3. Hook up controlled state to our rendered component

The last thing we need to do for our input is connected it to the controller. We're going to destructure the field object returned by the useController, and assign the root component value, onBlur, and onChange props with these field properties, giving react-hook-form full access to our component.

// components/TextInput.tsx > ControlledInput const ControlledInput = (props: TextInputProps) => { const formContext = useFormContext(); const { formState } = formContext; const { name, label, rules, defaultValue, ...inputProps } = props; const { field } = useController({ name, rules, defaultValue }); return ( /* ASSIGN PROPS ONCHANGETEXT, ONBLUR, AND VALUE TO CORRESPONDING FIELDS */ <View style={styles.container}> {label && (<Text style={styles.label}>{label}</Text>)} <View style={styles.inputContainer}> <RNTextInput style={styles.input} onChangeText={field.onChange} onBlur={field.onBlur} value={field.value} {...inputProps} /> </View> </View> ); }

4. Import the TextInput component into App.tsx

Now that our custom TextInput component is controlled by react-hook-form, let's go ahead and replace the Controlled component with our new controlled TextInput component inside App. tsx. To work properly, we will need to wrap our TextInputs with the FormProvider. We will need to pass all methods into the context by passing the methods from useForm to the FormProvider component. Now our TextInputs will be fully registered and controlled by react-hook-form. Our submit button will live outside of the FormProvider, but can receive the form data by passing handleSubmit with its success and error handlers.

// App.tsx import React from 'react'; import { TextInput } from './components/TextInput.jsx' import { Text, View, StyleSheet, Button, Alert, TouchableOpacity } from 'react-native'; /* IMPORT REACT HOOK FORM USEFORM, FORMPROVIDER, AND SUBMIT HANDLERS */ import { useForm, FormProvider, SubmitHandler, SubmitErrorHandler } from 'react-hook-form'; type FormValues = { email: string; password: string; }; const App () => { /* GET METHODS FROM USEFORM */ const {...methods} = useForm(); const onSubmit: SubmitHandler<FormValues> = (data) => console.log({data}); const onError: SubmitErrorHandler<FormValues> = (errors, e) => { return console.log(errors) }; /* WRAP TEXTINPUT COMPONENT IN FORM PROVIDER, PASSING CONTEXT METHODS INTO PROVIDER FOR THE SUBMIT BUTTON TO RECEIVE FORM DATA PASS METHOD HANDLESUBMIT WITH FUNCTION HANDLERS INTO ONPRESS */ return ( <View style={styles.container}> <FormProvider {...methods}> // pass all methods into the context <TextInput name="email" label="Email" placeholder="jon.doe@email.com" keyboardType="email-address" rules={{ required: 'Email is required!' }} /> <TextInput name="password" label="Password" secureTextEntry rules={{ required: 'Password is required!' }} /> </FormProvider> <View style={styles.button}> <Button title="Login" color="white" onPress={methods.handleSubmit(onSubmit, onError)} /> </View> </View> ); };

There you have it, we now have an extensible form that can scale and is much cleaner than the original example. This component makes it easy to add extra features like inline validation and error handling. To see a full example of this, you can find it here :

React Hook Form V7 - For React Native - Snack
Share this post

Interested in working with us?

Give us some details about your project, and our team will be in touch within a day or two.