React Native is a cross-platform mobile development framework based on JavaScript and Node.js. It allows you to build mobile applications for Android and iOS without writing code for each platform.
In this article, I'm going to walk you through building a simple form using Formik, perform some basic validations and unit testing, and then add a little bit of animations using the Animated API.
We are going to use Expo to set up our development environment, so let's first install Expo CLI globally.
npm install -g expo-cli
Now let's create an Expo project
npx create-expo-app react-native-formik
Once the installation finishes, you can run the app using the following command
expo start
You can view the app using the Xcode or Android Studio simulator, or scan the QR code shown on your terminal after you download the Expo Go app from the App Store or Google play, to be able to run the app on your mobile phone.
Let's now do some coding!
We will start by installing Formik
npm install formik --save
Formik is a lightweight form library for ReactJS and React Native. It provides you with a reusable form component which reduces a lot of boilerplate code and simplifies handling the three most annoying parts in the form building process:
- Getting values in and out of form state
- Validation and error messages
- Handling form submission
Copy the code below into App.js
import React from "react";
import {
TextInput,
Pressable,
Text,
View,
StyleSheet
} from "react-native";
import { Formik } from "formik";
export default function App() {
return (
<View style={styles.container}>
<Formik
initialValues={{ username: "", email: "" }}
onSubmit={(values) => console.log(values)}
>
{({
handleSubmit,
handleChange,
values
}) => {
return (
<View>
<TextInput
onChangeText={handleChange("username")}
value={values.username}
placeholder="username"
style={styles.input}
/>
<TextInput
onChangeText={handleChange("email")}
value={values.email}
placeholder="email"
style={styles.input}
/>
<View>
<Pressable
style={[styles.pressable, { transform: [{ scale }] }]}
onPress={handleSubmit}
onPressIn={onPressIn}
testID="button"
onPressOut={onPressOut}
>
<Text style={styles.buttonText}>submit</Text>
</Pressable>
</View>
</View>
);
}}
</Formik>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
input: {
width: 250,
height: 40,
marginBottom: 5,
borderWidth: 2,
padding: 5,
borderColor: "#000",
},
pressable: {
width: 250,
height: 40,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#000",
},
text: {
color: "#000",
},
buttonText: {
color: "#fff",
},
});
Here we are setting the Formik component
Notice that React Native does not provide a form component, so we are setting Formik and inside of it we are importing View as a wrapper for our form components, you can think of View as the equivalent of div in web development.
Button component supports a minimal level of customization and has no style property, so we are using Pressable as an alternative to customize our own button.
handleSubmit is auto generated from Formik to handle validation and submission.
We provide the Formik component with the initialValues object of our form. Here we are just assigning username and email.
Then we pass the handleChange method provided by Formik into the onChangeText callback in the TextField components with their corresponding key in the initialValues object.
And finally, we pass onSubmit as a prop into the Formik component, and here we are just printing the values to the console.
Validation:
Formik comes with built-in support for Yup which is a JavaScript schema builder for value parsing and validation.
Let's install Yup
npm install yup --save
Now we import Yup and create our validation schema
...
import * as Yup from "yup";
const Schema = Yup.object().shape({
username: Yup.string().required("username is required"),
email: Yup.string().email().required("email is required"),
});
...
<Formik
initialValues={{ username: "", email: "" }}
validationSchema={Schema}
onSubmit={(values) => console.log(values)}
>
{({
handleSubmit,
handleChange,
values,
errors,
touched
}) => {
...
{errors.username && touched.username && (
<Text style={styles.text}>{errors.username}</Text>)}{errors.email && touched.email && (
<Text style={styles.text}>{errors.email}</Text>)}
...
Here we are marking username and email as required fields, and adding the email method which uses a regex under the hood to validate the email format. Then we pass the schema into Formik's Yup config prop validationSchema, which will manage and map the error messages we set in the schema.
Unit Testing:
Unit testing is the practice of testing small pieces of code on their own, which makes it easier to track, control and refactor the code without any disruption.
We will use Jest as our test runner and Testing Library for providing the testing utilities needed for catching our components to perform actions upon them.
Let's starts by installing Expo's compatible version of Jest
npm install jest-expo jest --save-dev
Then we need to do some configurations in package.json
and add the testing script
{
...
"scripts": {
...
"test": "jest"
},
"jest": {
"preset": "jest-expo",
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)"
],
"collectCoverage": true,
"collectCoverageFrom": [
"**/*.{js,jsx}",
"!**/coverage/**",
"!**/node_modules/**",
"!**/babel.config.js",
"!**/jest.setup.js"
]
},
...
}
For more info about Jest configurations, visit the official docs here
Create a new folder __tests__
in the root directory and inside of it create App.test.js
which will include our tests.
Let's perform our first test case
Copy the code below into App.test.js
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react-native";
import App from "../App";
describe("Form Validations", () => {
it("does not show error messages when required values are fullfilled", async () => {
});
});
});
describe is used to organize a set of related test scenarios. Here, we are describing our testing block as "Form Validations" and it will contain all the positive and negative scenarios for the validations.
it is the method where we perform each individual test.
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react-native";
import App from "../App";
describe("Form Validations", () => {
it("does not show error messages when required values are fullfilled", async () => {
const { getByPlaceholderText, queryByText, getByTestId } = render(<App />);
let usernameInput = getByPlaceholderText("username");
let emailInput = getByPlaceholderText("email");
let submitButton = getByTestId("button");
fireEvent.changeText(usernameInput, "Mohamed");
fireEvent.changeText(emailInput, "mohamed@email.com");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("username is required")).toBeNull();
expect(queryByText("email is required")).toBeNull();
});
});
});
Our first scenario is where the user has filled in all the form inputs, so here we are assuming that the required warning does not show up.
We are rendering our form and then using the testing library special queries to get the components we need to perform the test on.
getByPlaceholderText searches for the input that holds the matching placeholder we provide.
getByTestId gets the component that holds the matching testID, so let’s get back to App.js and assign a testID to the Pressable component
fireEvent is used to invoke a given event handler, so we are using it here to change the input texts and then pressing the submit button that we got above. And we are using waitFor to wait until the press event is fulfilled.
...
it("shows error messages when required values are not fullfilled", async () => {
const { getByPlaceholderText, queryByText, getByTestId } = render(<App />);
let usernameInput = getByPlaceholderText("username");
let emailInput = getByPlaceholderText("email");
let submitButton = getByTestId("button");
fireEvent.changeText(usernameInput, "");
fireEvent.changeText(emailInput, "");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("username is required")).toBeTruthy();
expect(queryByText("email is required")).toBeTruthy();
});
});
...
The second scenario is where the user has not filled the form inputs, so we expect the required errors to show up.
toBeNull and toBeTruthy are Jest expectations to assume whether a specific component exists or not.
You can find more info about Jest expectation here
You have probably noticed how we repeatedly rendered the App component and defined the same variables in both tests, so to avoid the redundancy we use Jest's beforeEach
...
describe("Form Validations", () => {
let getByPlaceholderText,
queryByText,
getByTestId,
usernameInput,
emailInput,
submitButton;
beforeEach(() => {
({ getByPlaceholderText, queryAllByText, queryByText, getByTestId } =
render(<App />));
usernameInput = getByPlaceholderText("username");
emailInput = getByPlaceholderText("email");
submitButton = getByTestId("button");
});
it("does not show error messages when required values are fullfilled", async () => {
fireEvent.changeText(usernameInput, "Mohamed");
fireEvent.changeText(emailInput, "mohamed@email.com");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("username is required")).toBeNull();
expect(queryByText("email is required")).toBeNull();
});
});
it("shows error messages when required values are not fullfilled", async () => {
fireEvent.changeText(usernameInput, "");
fireEvent.changeText(emailInput, "");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("username is required")).toBeTruthy();
expect(queryByText("email is required")).toBeTruthy();
});
});
...
Now we perform the couple left scenarios where we expect whether a valid email is provided or not.
it("does not show error message when email is valid", async () => {
fireEvent.changeText(usernameInput, "Mohamed");
fireEvent.changeText(emailInput, "mohamed@email.com");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("email must be a valid email")).toBeNull();
});
});
it("shows error message when email is not valid", async () => {
fireEvent.changeText(usernameInput, "Mohamed");
fireEvent.changeText(emailInput, "mohamed");
await waitFor(() => {
fireEvent.press(submitButton);
});
await waitFor(() => {
expect(queryByText("email must be a valid email")).toBeTruthy();
});
});
Run the test script and make sure 4 tests have passed successfully!
Animated API:
Unlike ReactJS and web development, where we use CSS to create animations, React Native require us to use the Animated API to build and maintain animations.
Let's import Animated so that we can get started
...
import {
...
Animated,
} from "react-native";
...
We want to create a smooth transition on rendering the component for the first time, from the bottom of the screen up to the form original position in the center.
...
export default function App() {
const translateY = useRef(new Animated.Value(100)).current;
...
So here we create an instance of Animated.Value and wrap it in a useRef so that it gets created once, and then set the initial value to 100.
}) => {
...
return (
<Animated.View
style={{
transform: [{ translateY }],
}}
>
...
</Animated.View>
Then we replace the component that we want to apply the animation on which in this case is View with Animated.View, and we assign the translateY to the animated value instance we just created.
Animated exports six animatable component types: View, Text, Image, ScrollView, FlatList and SectionList, but you can also create your own using Animated.createAnimatedComponent().
...
useEffect(() => {
Animated.timing(translateY, {
toValue: 0,
duration: 500,
useNativeDriver: true,
}).start();
}, []);
...
Animated comes with helper functions that provide various animation types, for the on mount animation we will be using Animated.timing() which by default uses easeInOut curve but you can for sure pick your own easing function. After that we set the duration and target value.
To improve the animation performance, we are activating useNativeDriver which will allow native code to perform the animation directly on the UI thread.
By using the native driver, we send everything about the animation to native before starting the animation, allowing native code to perform the animation on the UI thread without having to go through the bridge on every frame. Once the animation has started, the JS thread can be blocked without affecting the animation.
And finally starting the animations by calling start()
Now let's add a pressing effect to the submit button
Let's create a new Animated.Value instance and set the initial value to 0
...
const btnAnimation = useRef(new Animated.Value(0)).current;
...
For the pressing effect we need to create scaling animation
const scale = btnAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.8],
});
Here we are interpolating and mapping animation with the scaling values, in other words, we are simply telling animated to set the scale value to 0.8 when the animated value is 1 and set it back to 1 when the animated value is 0.
You can find more information about interpolation in the official docs here
...
const onPressIn = () => {
Animated.spring(btnAnimation, {
toValue: 1,
useNativeDriver: true,
}).start();
};
const onPressOut = () => {
Animated.spring(btnAnimation, {
toValue: 0,
useNativeDriver: true,
}).start();
};
...
Here we are using the spring animation which provides a spring physics model
...
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
...
<AnimatedPressable
style={[styles.pressable, { transform: [{ scale }] }]}
onPress={handleSubmit}
onPressIn={onPressIn}
testID="button"
onPressOut={onPressOut}
>
<Text style={styles.ButtonText}>submit</Text>
</AnimatedPressable>
...
Since Animated is not exporting Pressable we need to use createAnimatedComponent to make the component animatable, then we replace Pressable with the newly created one, and add transform with the scale value assigned to it.
Snapshot testing:
Now that we have a stable working version of our app, we can perform snapshot testing. Snapshot testing ensures that there won't be any unexpected changes in UI. It typically takes a snapshot of the UI, compares the output with a stored copy, and if it detects any change occurred to the UI, the test simply fails.
it("renders correctly", () => {
const tree = render(<App />).toJSON();
expect(tree).toMatchSnapshot();
});
Run the test
A new folder __snapshots__
should be created in __tests__
, that's where your snapshot is stored.
Now try modifying any part of the form UI, and re-run the test
You will find the snapshot test fails!
Full code can be found in the github repo here.
Conclusion:
Now that you know the basics of creating an animated form in React Native, and learned how you can cut off a lot of unnecessary code with Formik, you can get the most out of this tutorial by adding more validations to the form and performing unit testing upon them, and then play around with interpolations in Animated.
Have a good one!
Top comments (0)