React Native Forms
Managing Form State while creating a better user experience.
Lets face it, managing form state in react sucks and it didn't get any better in react-native. There have been many libraries over the years that have aimed to help ease the pain but really only ended up exacerbating the issue. Luckily since the introduction of hooks react form management has sucked a lot less and there have been some amazing libraries that get us a lot farther. One of those libraries being react-hook-form which allows us to easily piece together a unified and manageable form state without having to reinvent the wheel every time.
In this article we are going to be taking a look at what the ideal form experience would look like from a users perspective but also how we can manage those Components without ripping our hair out. Follow along at https://github.com/Staceadam/blog-examples/tree/master/react-native/forms/Forms
Basic Example
We can start our journey by looking at a very naive implementation of a Form Component that would allow a user to input their name and age and submit it.
import React, { useState } from 'react'
import { View, TextInput, Button, StyleSheet, Alert } from 'react-native'
function BasicForm() {
const [text, setText] = useState(null)
const [age, setAge] = useState(null)
const submitHandler = () => {
Alert.alert('Form Submitted!', `text: ${text} & age: ${age}`, [
{ text: 'OK' }
])
}
return (
<View>
<TextInput
style={styles.input}
onChangeText={setText}
placeholder="name"
value={text}
/>
<TextInput
style={styles.input}
onChangeText={setAge}
value={age}
placeholder="age"
keyboardType="numeric"
/>
<Button title="submit" onPress={submitHandler} />
</View>
)
}
const styles = StyleSheet.create({
input: {
paddingHorizontal: 10,
height: 40,
margin: 12,
borderWidth: 1
}
})
export default BasicForm
Great but what are we missing from the equation here and how is this going to look when this Forms needs to change and new inputs get introduced with more complexity?
- having individual useState 's for each input is going to become very cumbersome, this does not feel like a declarative solution
- there is no validation happening, users can input whatever they want or submit the form entirely without having added any input
- the keyboard user experience is not ideal, the user can't easily transition from one input to another
We need a better way to unify these inputs logic into a declarative and scalable solution.
React-Hook-Form
React-Hook-Form to the rescue! react-hook-form allows us to have "Performant, flexible and extensible forms with easy-to-use validation". It utilizes React's hook api in order to register individual inputs into a larger form state that takes care of the same old expected input behavior so you don't have to.
Let's see how it can improve our BasicForm example.
First we can install the library into our project with npm install react-hook-form. react-hook-form exposes 2 main hooks that we are going to utilize here, useForm and useController. useForm is the overall form state for all of the inputs that we are going to be including into the form. useController is a hook that allows the individual inputs to communicate and update the form state. This is going to require us to create a wrapping component around the TextInput Component exposed by react-native.
To start let's include the useForm hook our HookForm and pick off what we need.
import { useForm, useController } from 'react-hook-form'
function HookForm() {
const { control, handleSubmit } = useForm()
}
control will be passed into each Input Component. handleSubmit will act as our form submission handler and return us our entire form state.
Next lets create our Input wrapper Component that will make use of the userController hook for each individual Input. userController takes in a name and control property in order to register itself with the form, it then returns us a field property which we can pass into our react-native TextInput Component. We also want to be able to tap into any of the TextInputs props, to do this we can spread the rest of the props given to Input into the TextInput.
function Input({ name, control, ...rest }) {
const { field } = useController({
control,
name
})
return (
<TextInput
{...rest}
style={styles.input}
value={field.value}
onChangeText={field.onChange}
placeholder={name}
/>
)
}
Almost there! Now we can use our Input component and pass it a name prop, which is a string value of how we want to refer to that inputs values, and a control prop, which is the mechanism exposed by userForm that takes care of updating the individual input.
function HookForm() {
const { control, handleSubmit } = useForm()
return (
<View style={styles.container}>
<Input name="name" control={control} />
<Input name="age" control={control} />
</View>
Finally we can make use of the handleSumbit property that will return to us our form data with each input fields value. You simply have to wrap your onPress handler with the handleSubmit method.
const onSubmit = data => {
// will return data object with
// {
// "name": "value",
// "age": "value"
// }
}
<Button title="submit" onPress={handleSubmit(onSubmit)} />
The final implementation looks like
import React from 'react'
import { View, TextInput, Button, StyleSheet, Alert } from 'react-native'
import { useForm, useController } from 'react-hook-form'
function Input({ name, control }) {
const { field } = useController({
control,
name
})
return (
<TextInput
style={styles.input}
value={field.value}
onChangeText={field.onChange}
placeholder={name}
/>
)
}
function HookForm() {
const { control, handleSubmit } = useForm()
const onSubmit = data => {
Alert.alert('Form Submitted!', JSON.stringify(data), [{ text: 'OK' }])
}
return (
<View style={styles.container}>
<Input name="name" control={control} />
<Input name="age" control={control} />
<Button title="submit" onPress={handleSubmit(onSubmit)} />
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'whitesmoke'
},
input: {
paddingHorizontal: 10,
height: 40,
margin: 12,
borderWidth: 1
}
})
export default HookForm
Now after a bit of boilerplate we have a much more scalable and declarative solution that allows us to simply pass a name and control prop into the Input Component. We still haven't solved a couple of our original issues though:
- validation
- ideal keyboard transitioning user experience
Validation
Luckily react-hook-form has our back again. useController has a rules property that comes with some sensible defaults as well as a validate method that allows you to run your own validation against the fields value.
Let's say we want both of the Inputs to be required, and our age input to validate against the input being over the age of 18.
We can start by extending our Input Components props to include a rules prop that will get fed into useController's rules property. Then we can use the fieldState object from userController , pick off the error and conditionally render a error border around our Input.
function Input({ name, control, rules, ...rest }) {
const { field, fieldState } = useController({
control,
name,
rules
})
return (
<TextInput
{...rest}
style={[styles.input, fieldState.error && { borderColor: 'red' }]}
value={field.value}
onChangeText={field.onChange}
placeholder={name}
/>
)
}
Now let's make sure both of the Inputs are required. We can pass a required: true to our rules prop on each Component. If a user tries to hit submit now without entering a value for both of our inputs, the submission will not fire and both of our inputs will be red to indicate they are required.
Finally we can check to see if the age of the user is 18 or older by using the validate property. validate will return the value in a callback and will fire an error if that value does not equate to true.
<Input
keyboardType='numeric'
name="age"
control={control}
rules={{ required: true, validate: value => value >= 18 }}
/>
Input Transitions
The last thing we need to take care of for the dream form experience is to allow the user to transition between inputs and the submit touchable from the keyboard. This is were things start to get a little messy. React-Native does not have a way for inputs to be aware of their sibling Components, this means we are going to have to create a series of refs using React.useRef that will allow us to explicitly target which ref we want to focus on. These refs in conjunction with the TextInput's onSubmitEditing will give us the base of keyboard transitions.
The 2 Components that we are going to be targeting in our example are the age Input and the Button. We can create refs for these by using the useRef hook from React and then feed them into their corresponding Component.
function Transitions() {
...
const ageInput = useRef()
const buttonInput = useRef()
...
<Input
ref={ageInput}
name="age"
control={control}
/>
<Button
ref={buttonInput}
title="submit"
onPress={handleSubmit(onSubmit)}
/>
...
Now we can use the onSubmitEditing prop from the TextInput Component to target these refs after the user hits 'return' on the keyboard.
function Transitions() {
...
const ageInput = useRef()
const buttonInput = useRef()
...
<Input
name="name"
control={control}
// calls the focus method on the next input
onSubmitEditing={() => ageInput.current.focus()}
/>
<Input
ref={ageInput}
name="age"
control={control}
// calls the focus method on the next input
onSubmitEditing={() => buttonInput.current.props.onPress()}
/>
<Button
ref={buttonInput}
title="submit"
onPress={handleSubmit(onSubmit)}
/>
...
To finish it off we can use the returnKeyType prop, https://reactnative.dev/docs/textinput#returnkeytype, to specify how our return key should read. Let's go with next for the name Input and done for the age Input.
The final Form including the correct returnKeyType 's looks like:
function Transitions() {
...
const ageInput = useRef()
const buttonInput = useRef()
...
return (
<View style={styles.container}>
<Input
name="name"
control={control}
returnKeyType="next"
onSubmitEditing={() => ageInput.current.focus()}
/>
<Input
ref={ageInput}
name="age"
control={control}
returnKeyType="done"
onSubmitEditing={() => buttonInput.current.props.onPress()}
/>
<Button
ref={buttonInput}
title="submit"
onPress={handleSubmit(onSubmit)}
/>
</View>
)
}
Summary
Wooohooo! We made it. This should give us all the tools we need to make the ideal form experience for the user and keep ourselves sane while trying to manage it.