React Native TypeScript react-hook-form

React Native Form Management Tutorial - Building a credit card form

A guide about creating declarative forms in React Native using react-hook-form by building credit card form example.

Last Update: January 18, 2021

18 min read––– views

Forms are pretty common in all kinds of apps. That's why developers are often trying to simplify the process of building forms. I've built some custom solutions before, also used all the popular form management libraries so far. I think react-hook-form is the best one in terms of developer experience and customization.

It's pretty straightforward to use it on the web. You simply create your HTML input elements and register them. But it's a little harder with React Native. So I'll try describing each step I took to be able to make my approach more clear. I'll be building a credit card form in this tutorial but the tutorial should be helpful with building any types of forms. Most of the components we'll be building here can be reused as well.

You may find the full version of this component on Github. I also ported the React Native code into the web thanks to react-native-web.

Play with it here 👇

Table of Contents

Starting with a simple UI

For this tutorial, I used this clean design I found on Dribbble as the design reference. I've also used the TextField component I built in my last post. Here is the CreditCardForm component that generates the UI with simple local state variables:

CreditCardForm.tsx
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import Button from './Button';
import TextField from './TextField';
const CreditCardForm: React.FC = () => {
const [name, setName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [expiration, setExpiration] = useState('');
const [cvv, setCvv] = useState('');
function onSubmit() {
console.log('form submitted');
}
return (
<View>
<TextField
style={styles.textField}
label="Cardholder Name"
value={name}
onChangeText={(text) => setName(text)}
/>
<TextField
style={styles.textField}
label="Card Number"
value={cardNumber}
onChangeText={(text) => setCardNumber(text)}
/>
<View style={styles.row}>
<TextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
label="Expiration Date"
value={expiration}
onChangeText={(text) => setExpiration(text)}
/>
<TextField
style={styles.textField}
label="Security Code"
value={cvv}
onChangeText={(text) => setCvv(text)}
/>
</View>
<Button title="PAY $15.12" onPress={onSubmit} />
</View>
);
};
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
marginBottom: 36,
},
textField: {
flex: 1,
marginTop: 24,
},
});
export default CreditCardForm;

I'm simply including the form in a ScrollView on the App component:

App.tsx
import React, { useState } from 'react';
import { StyleSheet, Text, ScrollView } from 'react-native';
import CreditCardForm from './components/CreditCardForm';
const App: React.FC = () => {
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Payment details</Text>
<CreditCardForm />
</ScrollView>
);
};
const styles = StyleSheet.create({
content: {
paddingTop: 96,
paddingHorizontal: 36,
},
title: {
fontFamily: 'Avenir-Heavy',
color: 'black',
fontSize: 32,
marginBottom: 32,
},
});
export default App;

Integrating react-hook-form

Using react-hook-form provides subtle benefits over building form logics manually. The most obvious advantages are building more readable code, easier maintenance, and more reusability.

So let's start by adding react-hook-form to our project:

npm install react-hook-form
// or
yarn add react-hook-form

You may use any TextInput component you have inside react-hook-form. It has a special Controller component that helps to register the input to the library.

This is the minimum code block needed to build a React Native form with react-hook-form:

App.tsx
import React from 'react';
import { View, Text, TextInput } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
export default function App() {
const { control, handleSubmit, errors } = useForm();
const onSubmit = (data) => console.log(data);
return (
<View>
<Controller
control={control}
render={({ onChange, onBlur, value }) => (
<TextInput
style={styles.input}
onBlur={onBlur}
onChangeText={(value) => onChange(value)}
value={value}
/>
)}
name="firstName"
rules={{ required: true }}
defaultValue=""
/>
{errors.firstName && <Text>This is required.</Text>}
</View>
);
}

While this is good enough for a single input, it's a better idea to create a generic wrapper input component that handles repetitive work such as using the Controller and displaying the error message. For that purpose, I'm going to create FormTextField. It will need to access some of the properties that are returned from the useForm method. We may pass those values as a prop from CreditCardForm to FormTextField but that'd mean repeating the same prop for each input. Fortunately, react-hook-form provides the useFormContext method which lets you access all the form properties in deeper component levels.

And FormTextField will look like this:

FormTextField.tsx
import React from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import TextField from './TextField';
type Props = React.ComponentProps<typeof TextField> & {
name: string;
};
const FormTextField: React.FC<Props> = (props) => {
const { name, ...restOfProps } = props;
const { control, errors } = useFormContext();
return (
<Controller
control={control}
render={({ onChange, onBlur, value }) => (
<TextField
// passing everything down to TextField
// to be able to support all TextInput props
{...restOfProps}
errorText={errors[name]?.message}
onBlur={onBlur}
onChangeText={(value) => onChange(value)}
value={value}
/>
)}
name={name}
/>
);
};
export default FormTextField;

Now, it's time to migrate our form components to react-hook-form. We'll simply replace TextFields with our new FormTextField component, replace local state variables with a single form model, and wrap our form with FormProvider.

Note that it's very easy to create Typescript types for our form. You'll need to build a FormModel type that contains each field in your form. Notice that the field names should match the ones you're passing into FormTextField. The library will update the right field based on that prop.

After those changes, the new version of CreditCardForm will look like below. You may check out the full diff on Github.

CreditCardForm.tsx
interface FormModel {
holderName: string;
cardNumber: string;
expiration: string;
cvv: string;
}
const CreditCardForm: React.FC = () => {
const formMethods = useForm<FormModel>({
defaultValues: {
holderName: '',
cardNumber: '',
expiration: '',
cvv: '',
},
});
function onSubmit(model: FormModel) {
console.log('form submitted', model);
}
return (
<View>
<FormProvider {...formMethods}>
<FormTextField
style={styles.textField}
name="holderName"
label="Cardholder Name"
/>
<FormTextField
style={styles.textField}
name="cardNumber"
label="Card Number"
/>
<View style={styles.row}>
<FormTextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
name="expiration"
label="Expiration Date"
/>
<FormTextField
style={styles.textField}
name="cvv"
label="Security Code"
keyboardType="number-pad"
/>
</View>
<Button
title="PAY $15.12"
onPress={formMethods.handleSubmit(onSubmit)}
/>
</FormProvider>
</View>
);
};

Improving reusability

I had to make a decision at this point in terms of the better reusability of the form. It's about where to create our form initially using the useForm method. We have two options:

  1. Defining the form inside CreditCardForm as the way it is. This makes sense if you'll use the credit card form in a single flow/screen. You don't have to redefine the form and pass it through FormProvider in multiple places this way.
  2. Defining the form in CreditCardForm's parent, which is the component that consumes it. You'll have access to all react-hook-form methods this way and you may build independent stuff upon everything CreditCardForm provides. Let's say you have two screens: one for paying for a product, and the other is just for registering a credit card. Buttons should look different in those cases.

Here is one example about the second option. In this example, we are watching the card number value changes and updating the button title based on that:

App.tsx
const App: React.FC = () => {
const formMethods = useForm<FormModel>({
// to trigger the validation on the blur event
mode: 'onBlur',
defaultValues: {
holderName: 'Halil Bilir',
cardNumber: '',
expiration: '',
cvv: '',
},
})
const cardNumber = formMethods.watch('cardNumber')
const cardType = cardValidator.number(cardNumber).card?.niceType
function onSubmit(model: FormModel) {
Alert.alert('Success')
}
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Payment details</Text>
<CreditCardForm />
<FormProvider {...formMethods}>
<Text style={styles.title}>Payment details</Text>
<CreditCardForm />
<Button
title={cardType ? `PAY $15.12 WITH ${cardType}` : 'PAY $15.12'}
onPress={formMethods.handleSubmit(onSubmit)}
/>
</FormProvider>
</ScrollView>
)
}

I'll go with the second option.

Validations

react-hook-form lets us defining validations simply by passing rules to the Controller. Let's start by adding that to FormTextField:

FormTextField.tsx
import { useFormContext, Controller } from 'react-hook-form'
import { useFormContext, Controller, RegisterOptions } from 'react-hook-form'
import TextField from './TextField'
type Props = React.ComponentProps<typeof TextField> & {
name: string
rules: RegisterOptions
}
const FormTextField: React.FC<Props> = (props) => {
const { name, ...restOfProps } = props
const { name, rules, ...restOfProps } = props
const { control, errors } = useFormContext()
return (
@@ -25,6 +26,7 @@ const FormTextField: React.FC<Props> = (props) => {
/>
)}
name={name}
rules={rules}
/>
)
}

For the tutorial, I'll delegate the validations logic to Braintree's card-validator library to keep us focused on the form part. Now I need to define rules for our FormTextField components. rules object will contain two properties:

  1. required: This takes a message that is displayed when the field is empty.
  2. validate.{custom_validation_name}: We may create a custom validation method here. I'm going to use it for validating the integrity of the input value using card-validation library.

Our input fields will need to look like below. You may check out the full diff of validation rules on Github.

CreditCardForm.tsx
<>
<FormTextField
style={styles.textField}
name="holderName"
label="Cardholder Name"
rules={{
required: 'Cardholder name is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.cardholderName(value).isValid ||
'Cardholder name looks invalid.'
);
},
},
}}
/>
<FormTextField
style={styles.textField}
name="cardNumber"
label="Card Number"
keyboardType="number-pad"
rules={{
required: 'Card number is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.number(value).isValid ||
'This card number looks invalid.'
);
},
},
}}
/>
<FormTextField
style={[
styles.textField,
{
marginRight: 24,
},
]}
name="expiration"
label="Expiration Date"
rules={{
required: 'Expiration date is required.',
validate: {
isValid: (value: string) => {
return (
cardValidator.expirationDate(value).isValid ||
'This expiration date looks invalid.'
);
},
},
}}
/>
<FormTextField
style={styles.textField}
name="cvv"
label="Security Code"
keyboardType="number-pad"
maxLength={4}
rules={{
required: 'Security code is required.',
validate: {
isValid: (value: string) => {
const cardNumber = formMethods.getValues('cardNumber');
const { card } = cardValidator.number(cardNumber);
const cvvLength = card?.type === 'american-express' ? 4 : 3;
return (
cardValidator.cvv(value, cvvLength).isValid ||
'This security code looks invalid.'
);
},
},
}}
/>
</>

After making those changes, we'll see the following screen when clicking on the PAY button:

validations

Triggering validations

The validation trigger scheme is configurable with react-hook-form without any custom code. mode parameter configures the validation trigger scheme:

  • onChange: Validation will trigger on the submit event and invalid inputs will attach onChange event listeners to re-validate them.
  • onBlur: Validation will trigger on the blur event.
  • onTouched: Validation will trigger on the first blur event. After that, it will trigger on every change event.

While those modes are enough for most cases, I wanted a custom behavior with my form. I want to provide fast feedback to the user but it shouldn't be too fast as well. This means I want to validate my input right after the user enters enough characters. That's why I created an effect in FormTextField that watches the input value and triggers the validation when it passes a certain threshold(validationLength prop here).

Please note that this is not required for the form to function at all, and it may cost some performance penalty if your validation method is intensive.

FormTextField.tsx
type Props = React.ComponentProps<typeof TextField> & {
name: string
rules: RegisterOptions
validationLength?: number
}
const FormTextField: React.FC<Props> = (props) => {
const { name, rules, ...restOfProps } = props
const { control, errors } = useFormContext()
const {
name,
rules,
validationLength = 1,
...restOfProps
} = props
const { control, errors, trigger, watch } = useFormContext()
const value = watch(name)
useEffect(() => {
if (value.length >= validationLength) {
trigger(name)
}
}, [value, name, validationLength, trigger])

Formatting input values

To make the card number and expiration input fields look good, I'll format their values instantly with each new character users enter.

  • Credit card number: I'll format its value in XXXX XXXX XXXX XXXX format.
  • Expiration date: I'll format its value in MM/YY format.

There are some libraries that do a similar job but I want to create a simple solution on my own. So I created utils/formatters.ts file for this purpose:

utils/formatters.ts
export function cardNumberFormatter(
oldValue: string,
newValue: string,
): string {
// user is deleting so return without formatting
if (oldValue.length > newValue.length) {
return newValue;
}
return newValue
.replace(/\W/gi, '')
.replace(/(.{4})/g, '$1 ')
.substring(0, 19);
}
export function expirationDateFormatter(
oldValue: string,
newValue: string,
): string {
// user is deleting so return without formatting
if (oldValue.length > newValue.length) {
return newValue;
}
return newValue
.replace(/\W/gi, '')
.replace(/(.{2})/g, '$1/')
.substring(0, 5);
}

Now we'll simply create a formatter prop for FormTextField component, and pass the value it returns to onChange:

FormTextField.tsx
onChangeText={(value) => onChange(value)}
onChangeText={(text) => {
const newValue = formatter ? formatter(value, text) : text
onChange(newValue)
}}
value={value}
/>
)}

I created some tests to make sure format utilities return the expected values using jest's test.each method. I hope it'll make it easier for you to understand what those utils methods are doing:

utils/formatters.test.ts
import { cardNumberFormatter, expirationDateFormatter } from './formatters';
describe('cardNumberFormatter', () => {
test.each([
{
// pasting the number
oldValue: '',
newValue: '5555555555554444',
output: '5555 5555 5555 4444',
},
{
// trims extra characters
oldValue: '',
newValue: '55555555555544443333',
output: '5555 5555 5555 4444',
},
{
oldValue: '555',
newValue: '5555',
output: '5555 ',
},
{
// deleting a character
oldValue: '5555 5',
newValue: '5555 ',
output: '5555 ',
},
])('%j', ({ oldValue, newValue, output }) => {
expect(cardNumberFormatter(oldValue, newValue)).toEqual(output);
});
});
describe('expirationDateFormatter', () => {
test.each([
{
// pasting 1121
oldValue: '',
newValue: '1121',
output: '11/21',
},
{
// pasting 11/21
oldValue: '',
newValue: '11/21',
output: '11/21',
},
{
oldValue: '1',
newValue: '12',
output: '12/',
},
{
// deleting a character
oldValue: '12/2',
newValue: '12/',
output: '12/',
},
])('%j', ({ oldValue, newValue, output }) => {
expect(expirationDateFormatter(oldValue, newValue)).toEqual(output);
});
});

Focusing on the next field

I believe this is a good UX pattern for forms: focusing on the next input field when the user has filled the current input. There are two possible ways to understand when the user is done:

  1. Listening to the onSubmitEditing event of the input. This is invoked when users click on the return button of the keyboard.
  2. Checking the input validation results: it means the user has entered all the necessary characters for the credit card, expiration, and CVV fields whenever they are valid.

I'll use the first method on the cardholder name input, and the second one on the rest. It's simply because we don't know when the cardholder's name is completed, unlike other ones.

We need to keep refs for each input, and invoke nextTextInputRef.focus method appropriately. We have two custom components that wrap the React Native TextInput: they are FormTextField and TextField. So we have to use React.forwardRef to make sure ref is attached to the native TextInput.

Here are the steps I followed to build this:

  • Wrapped FormTextField and TextField with React.forwardRef:
import { TextInput } from "react-native"
// components/FormTextField.tsx
const FormTextField: React.FC<Props> = (props) => {
const FormTextField = React.forwardRef<TextInput, Props>((props, ref) => {
// components/TextField.tsx
const TextField: React.FC<Props> = (props) => {
const TextField = React.forwardRef<TextInput, Props>((props, ref) => {
  • Created onValid prop on FormTextField component, and modified the effect that triggers validation:
FormTextField.tsx
useEffect(() => {
async function validate() {
const isValid = await trigger(name)
if (isValid) onValid?.()
}
if (value.length >= validationLength) {
trigger(name)
validate()
}
}, [value, name, validationLength, trigger])
  • Created a ref for each component and triggered the next input ref's onFocus method:
CreditCardForm.tsx
const holderNameRef = useRef<TextInput>(null)
const cardNumberRef = useRef<TextInput>(null)
const expirationRef = useRef<TextInput>(null)
const cvvRef = useRef<TextInput>(null)
<>
<FormTextField
ref={holderNameRef}
name="holderName"
label="Cardholder Name"
onSubmitEditing={() => cardNumberRef.current?.focus()}
/>
<FormTextField
ref={cardNumberRef}
name="cardNumber"
label="Card Number"
onValid={() => expirationRef.current?.focus()}
/>
<FormTextField
ref={expirationRef}
name="expiration"
label="Expiration Date"
onValid={() => cvvRef.current?.focus()}
/>
<FormTextField
ref={cvvRef}
name="cvv"
label="Security Code"
onValid={() => {
// form is completed so hide the keyboard
Keyboard.dismiss()
}}
/>
</>

You can check out the full diff of this section on Github.

Displaying the card type icon

This is our last feature. I created the CardIcon component for this, and I'll pass it to the input through the endEnhancer prop.

CardIcon.tsx
import React from 'react';
import { Image, StyleSheet } from 'react-native';
import cardValidator from 'card-validator';
const VISA = require('./visa.png');
const MASTERCARD = require('./mastercard.png');
const AMEX = require('./amex.png');
const DISCOVER = require('./discover.png');
type Props = {
cardNumber: string;
};
const CardIcon: React.FC<Props> = (props) => {
const { cardNumber } = props;
const { card } = cardValidator.number(cardNumber);
let source;
switch (card?.type) {
case 'visa':
source = VISA;
break;
case 'mastercard':
source = MASTERCARD;
break;
case 'discover':
source = DISCOVER;
break;
case 'american-express':
source = AMEX;
break;
default:
break;
}
if (!source) return null;
return <Image style={styles.image} source={source} />;
};
const styles = StyleSheet.create({
image: {
width: 48,
height: 48,
},
});
export default CardIcon;

You can review the full diff for the card icon over here.

Testing

I will create some tests for the critical parts of the form to make sure we'll know instantly when they are breaking, which are validations, value formattings, and form submission.

I love using react-native-testing-library for my tests. It lets you create tests similar to user behavior.

I'm also using bdd-lazy-var, the tool I learned about in my last job. I still pick it up on my tests as it helps to describe the test variables in a clean and more readable way.

So I'll set up a form with useForm and pass it through the FormProvider just like using it on an actual screen. I'll then change input values, test validation results, and check the result react-hook-form returns when I submit the button. Here is the base setup I'll use in all of my test cases:

CreditCardForm.test.tsx
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import { def, get } from 'bdd-lazy-var/getter';
import { useForm, FormProvider } from 'react-hook-form';
import { Button } from 'react-native';
import CreditCardForm from './CreditCardForm';
const FormWrapper = () => {
const formMethods = useForm({
mode: 'onBlur',
defaultValues: {
holderName: '',
cardNumber: '',
expiration: '',
cvv: '',
},
});
const { handleSubmit } = formMethods;
const onSubmit = (model) => {
get.onSubmit(model);
};
return (
<FormProvider {...formMethods}>
<CreditCardForm />
<Button onPress={handleSubmit(onSubmit)} title={'Submit'} />
</FormProvider>
);
};
def('render', () => () => render(<FormWrapper />));
def('onSubmit', () => jest.fn());

Testing credit card number validation

I have three assertions in this test case:

  1. The validation is not triggered before I type in 16 characters.
  2. An error is displayed when I enter an invalid credit card number.
  3. The error disappears when I enter a valid card number.
CreditCardForm.test.tsx
it('validates credit card number', async () => {
const { queryByText, getByTestId } = get.render();
// does not display validation message until input is filled
const cardInput = getByTestId('TextField.cardNumber');
fireEvent.changeText(cardInput, '55555555');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).toBeNull();
});
// invalid card
fireEvent.changeText(cardInput, '5555555555554440');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).not.toBeNull();
});
// valid card
fireEvent.changeText(cardInput, '5555 5555 5555 4444');
await waitFor(() => {
expect(queryByText(/This card number looks invalid./)).toBeNull();
});
});

Testing expiration date validation

Testing with passed and valid dates, and checking the validation error is displayed/hidden:

CreditCardForm.test.tsx
it('validates expiration date', async () => {
const { queryByText, getByTestId } = get.render();
const input = getByTestId('TextField.expiration');
// passed expiration date
fireEvent.changeText(input, '1018');
await waitFor(() =>
expect(queryByText(/This expiration date looks invalid./)).not.toBeNull(),
);
// valid date
fireEvent.changeText(input, '10/23');
await waitFor(() =>
expect(queryByText(/This expiration date looks invalid./)).toBeNull(),
);
});

Testing the form submission

Entering correct values to each input and clicking on the submit button. I then expect the onSubmit method is called with the correct and formatted data:

CreditCardForm.test.tsx
it('submits the form', async () => {
const { getByText, getByTestId } = get.render();
fireEvent.changeText(getByTestId('TextField.holderName'), 'Halil Bilir');
fireEvent.changeText(getByTestId('TextField.cardNumber'), '5555555555554444');
fireEvent.changeText(getByTestId('TextField.expiration'), '0224');
fireEvent.changeText(getByTestId('TextField.cvv'), '333');
fireEvent.press(getByText('Submit'));
await waitFor(() =>
expect(get.onSubmit).toHaveBeenLastCalledWith({
holderName: 'Halil Bilir',
// cardNumber and expiration are now formatted
cardNumber: '5555 5555 5555 4444',
expiration: '02/24',
cvv: '333',
}),
);
});

Output

Here is a screencast of what we've build so far:

You can find the full version on Github. Please feel free to send me a message over Twitter if you have any feedback or questions.