What are we doing?
Every app you build will need a login/signup funnel. It's one of the most basic building blocks of a modern app. Let's learn how to do it the fast and easy way using React Native.
Our goal is to create a React Native app with the following features:
- A user can create an account
- A user can log into their account
- Once logged in, the user can access the app home screen
- The user can manually log out
- When automatically logged out due to their session expiring, the user is taken to the login screen
Requirements
You need a general knowledge of how modern JavaScript development works. I won't be explaining the nitty gritty of things like npm
or how React Native actually functions.
Setting up our project
Follow this guide to install dependencies and create your project. This tutorial will be aimed at developing for iOS as the target OS, using the React Native CLI approach (rather than Expo). If you want to use Expo and/or Android, you can probably modify some of the content to make it work.
Our project name can be whatever you want, but I'll be using AuthenticationBoilerplate
.
By the end of the linked guide, you should have your project created and running in the iOS simulator.
Part 1: Navigation
Our app includes the following navigation flows:
- When the user logs in successfully, they are taken to the home screen.
- When the user clicks the 'Create account' on the login screen, they are taken to the create account screen.
- When the user clicks the 'Log in' link on the create account screen, they are taken to the login screen.
- When the user clicks the 'Log out' link on the home screen, they are taken to the login screen.
A whole bunch of functionality will need to happen between this navigation, but making sure we can navigate successfully seems like a good start.
To support moving between screens, we're going to use the react-navigation
package.
Setting up react-navigation
Installing the necessary dependencies should be straightforward:
yarn add react-navigationyarn add react-native-reanimated react-native-gesture-handler react-native-screens react-navigation-stackcd iospod installcd ..
After running pod install
, you should see something close to the following output:
Detected React Native module pods for RNGestureHandler, RNReanimated, and RNScreensAnalyzing dependenciesDownloading dependenciesInstalling RNGestureHandler (1.5.2)Installing RNReanimated (1.4.0)Installing RNScreens (2.0.0-alpha.22)Generating Pods projectIntegrating client projectPod installation complete! There are 31 dependencies from the Podfile and 29 total pods installed.
If that doesn't work, check their (official guide to getting started)[https://reactnavigation.org/docs/en/getting-started.html].
Creating our screens
As established above, we need three screens:
- HomeScreen
- LoginScreen
- CreateAccountScreen
In our project, create a new directory called src/
and then a new directory inside that one called screens/
. Inside src/screens/
, create a file for each of our screens (e.g. HomeScreen.js
).
For now, each file can look roughly like so:
import React from "react";import { View, Text } from "react-native";const LoginScreen = () => {return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>LoginScreen</Text></View>);};export default LoginScreen;
Creating our navigator between screens
react-navigation
uses the concept of a "stack navigator" for basic navigation. In short, it's a stack of screens. Open up App.js
in the root of your project and delete its contents.
Add the following to set up our navigation:
import { createAppContainer } from "react-navigation";import { createStackNavigator } from "react-navigation-stack";import LoginScreen from "./src/screens/LoginScreen";import HomeScreen from "./src/screens/HomeScreen";import CreateAccountScreen from "./src/screens/CreateAccountScreen";const AppNavigator = createStackNavigator({Home: HomeScreen,Login: LoginScreen,CreateAccount: CreateAccountScreen,},{initialRouteName: "Login",});export default createAppContainer(AppNavigator);
Reload your app (CMD + R from the simulator on Mac) and you should see the text 'LoginScreen'.
If you get the error Direction is undefined
, try restarting the simulator, or double check you ran pod install
when installing the packages as described above.
Simple navigation
Alright, let's set up some simple navigation actions. To summarize, each screen allows the following actions:
- From the LoginScreen, the user can go to the CreateAccountScreen or the HomeScreen.
- From the CreateAccountScreen, the user can go back to the LoginScreen.
- From the HomeScreen, the user can go back to the LoginScreen.
Each screen automatically receives a navigation
prop by virtue of the stack navigator. Let's set up some buttons for easy navigation.
HomeScreen:
import React from "react";import { View, Text, Button } from "react-native";const HomeScreen = ({ navigation }) => {return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>HomeScreen</Text><Button title="Log out" onPress={() => navigation.navigate("Login")} /></View>);};export default HomeScreen;
LoginScreen:
import React from "react";import { View, Text, Button } from "react-native";const LoginScreen = ({ navigation }) => {return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>LoginScreen</Text><Button title="Log in" onPress={() => navigation.navigate("Home")} /><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/></View>);};export default LoginScreen;
... and I'm sure you can figure out CreateAccountScreen
from there.
With that done, you should be able to click around your app, moving from screen to screen. Success!
Mock API
For real authentication, we'll call an API similar to the one we built in (my Rails authentication tutorial)[https://scottdomes.dev/articles/rails-authentication-deploy/]. But for now, for easy development, we're going to create a mock API.
A good mock API should do three things:
- Include a delay for sending/receiving data.
- Mimic the inputs and outputs of a request.
In our src/
directory, create a new folder called api/
and a new file called mock.js
inside.
Two initial methods:
const mockSuccess = (value) => {return new Promise((resolve) => {setTimeout(() => resolve(value), 2000);});};const mockFailure = (value) => {return new Promise((resolve, reject) => {setTimeout(() => reject(value), 2000);});};
These methods mock the success or failure of an API call (based on a promise) after two seconds, and return the passed-in value.
This allows us to create our login method:
export const login = (email, password, shouldSucceed = true) => {console.log(email, password);if (!shouldSucceed) {return mockFailure({ error: 500, message: "Something went wrong!" });}return mockSuccess({ auth_token: "successful_fake_token" });};
The shouldSucceed
parameter allows us to dictate if we want to mock the success of failure of this request. This parameter is for development only, and will soon be removed.
If the request is successful, we return a JSON web token. For more, see my Rails authentication tutorial.
If the request is not successful, we return a human-readable error.
Make the same method for createAccount
:
export const createAccount = (email, password, shouldSucceed = true) => {console.log(email, password);if (!shouldSucceed) {return mockFailure({ error: 500, message: "Something went wrong!" });}return mockSuccess({ auth_token: "successful_fake_token" });};
And then a couple of methods for fetching the list of users:
const getAuthenticationToken = () => "successful_fake_token";export const getUsers = (shouldSucceed = true) => {const token = getAuthenticationToken();if (!shouldSucceed) {return mockFailure({ error: 401, message: "Invalid Request" });}return mockSuccess({users: [{email: "test@test.ca",},{email: "test2@test.ca",},],});};
This method will send the appropriate token. We'll load the user list from the HomeScreen
. The error ('Invalid Request') will let us know the user is not properly authenticated, and should return to the LoginScreen
.
Now that our mock API is in place, let's start wiring requests to our screens.
Login request
Inside our LoginScreen
, make a new method to call the login
method:
import React from "react";import { View, Text, Button } from "react-native";import { login } from "../api/mock";const LoginScreen = ({ navigation }) => {const loginUser = () => {login("test@test.ca", "password").then(() => {navigation.navigate("Home");}).catch((err) => console.log("error:", err.message));};return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>LoginScreen</Text><Button title="Log in" onPress={loginUser} /><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/></View>);};export default LoginScreen;
If the request succeeds (which it should automatically), we should see our fake email + password logged in the console, and then after 2 seconds we will be redirected to the HomeScreen
.
If you pass in false
as the last parameter of login
, you should NOT be redirected, and you should see the error in the console.
We can do something similar for createAccount
:
import React from "react";import { View, Text, Button } from "react-native";import { createAccount } from "../api/mock";const CreateAccount = ({ navigation }) => {const createUser = () => {createAccount("test@test.ca", "password").then((val) => {navigation.navigate("Home");}).catch((err) => console.log("error:", err.message));};return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>CreateAccount</Text><Button title="Create user" onPress={createUser} /><Button title="Log in" onPress={() => navigation.navigate("Login")} /></View>);};export default CreateAccount;
Error display
When our login/signup fails, we want to show a message to the user. This is easy to do with React hooks.
Here's an example for LoginScreen
:
import React, { useState } from "react";import { View, Text, Button } from "react-native";import { login } from "../api/mock";const LoginScreen = ({ navigation }) => {const [errorMessage, setErrorMessage] = useState("");const loginUser = () => {setErrorMessage("");login("test@test.ca", "password", false).then(() => {navigation.navigate("Home");}).catch((err) => setErrorMessage(err.message));};return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>LoginScreen</Text><Button title="Log in" onPress={loginUser} /><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/>{errorMessage ? <Text>{errorMessage}</Text> : null}</View>);};export default LoginScreen;
When the request fails, the error mesage should appear below the buttons.
You can copy this code for CreateAccountScreen
.
(Some may point out that these two screens are very similar and could share code. We'll fix this in the future!)
Loading users
As mentioned above, we want to load a list of existing users on our HomeScreen
. If that request fails with a 401
error, the user's session has expired, and we want to redirect them to the LoginScreen
.
To do so, we're going to refactor HomeScreen
to be a class component using a componentDidMount
lifecycle method.
import React from "react";import { View, Text, Button } from "react-native";import { getUsers } from "../api/mock";export default class HomeScreen extends React.Component {state = { users: [], hasLoadedUsers: false, userLoadingErrorMessage: "" };loadUsers() {this.setState({ hasLoadedUsers: false, userLoadingErrorMessage: "" });getUsers().then((res) =>this.setState({hasLoadedUsers: true,users: res.users,})).catch(this.handleUserLoadingError);}handleUserLoadingError = (res) => {if (res.error === 401) {this.props.navigation.navigate("Login");} else {this.setState({hasLoadedUsers: false,userLoadingErrorMessage: res.message,});}};componentDidMount() {this.loadUsers();}render() {return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>HomeScreen</Text>{this.state.users.map((user) => (<Text key={user.email}>{user.email}</Text>))}<Buttontitle="Log out"onPress={() => this.props.navigation.navigate("Login")}/></View>);}}
There's a lot going on here, so let's unpack it:
- When we first hit the HomeScreen, we try load the list of users.
- If the request succeeds, we display a list of their emails.
- If the request fails, and the error code is 401, we are redirected back to the login screen.
- If the request fails with another code, an error message is displayed.
Try experimenting with the various results listed above. You can pass false
to getUsers
to make the request fail, and change the returned error code in api/mock.js
to see what happens.
We've now successfully mocked the login flow for our app. Now we start making it more real. First step: start really using an authentication token.
Storing the auth token
With our final app, our authentication flow will go like so:
- Create an account or log in, and get a token back from the API.
- Save that token to device storage.
- Include that token in every request (e.g. to
getUsers
). - If the API tells us the token is invalid (if it has expired), redirect back to the login screen.
Most of the above is in place with our mock API, except for storing the token on the device. Let's do that now.
Token saving
Create a new file in api/
called token.js
. This file is where we'll store methods for getting and saving the token.
Luckily, accessing device storage is very easy with React Native, using the async-storage
package. You can read more about it here.
Note that async-storage
isn't particulary secure, so don't use it for important information. See this blog post for an overview of more secure storage options.
To get started with the package, install and link it:
yarn add @react-native-community/async-storagecd ios/pod installcd ..
Here's what our token.js
should look like:
import AsyncStorage from "@react-native-community/async-storage";export const getToken = async () => {try {const value = await AsyncStorage.getItem("@auth_token");if (value !== null) {return value;}} catch (e) {return null;}};export const setToken = async (token) => {try {await AsyncStorage.setItem("@auth_token", token);} catch (e) {return null;}};
We add a getter and setter for @auth_token
, along with some very basic error handling. Note that if the token is not get or set properly, the effect will be the user being redirected to the login screen on their next failed request.
Wiring up our token
Here's how we can modify LoginScreen
to set the token after a successful request:
import React, { useState } from "react";import { View, Text, Button } from "react-native";import { login } from "../api/mock";import { setToken } from "../api/token";const LoginScreen = ({ navigation }) => {const [errorMessage, setErrorMessage] = useState("");const loginUser = async () => {setErrorMessage("");login("test@test.ca", "password").then(async (res) => {await setToken(res.auth_token);navigation.navigate("Home");}).catch((err) => setErrorMessage(err.message));};return (<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>LoginScreen</Text><Button title="Log in" onPress={loginUser} /><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/>{errorMessage ? <Text>{errorMessage}</Text> : null}</View>);};export default LoginScreen;
We can do the same on CreateAccountScreen
.
Note that for cleanliness, we will eventually abstract the token setting out of this component, since it's not really [that component's responsibility(https://scottdomes.dev/articles/tiny-components/). But baby steps.
As for token setting, in api/mock.js
, we can import the right method:
import { getToken } from "./token";
And then use it (note the new check for token correctness):
export const getUsers = async (shouldSucceed = true) => {const token = await getToken();if (token !== "successful_fake_token") {return mockFailure({ error: 401, message: "Invalid Request" });}return mockSuccess({users: [{email: "test@test.ca",},{email: "test2@test.ca",},],});};
Try logging in now (with shouldSucceed
set to true
for all requests). Everything should work smoothly. Nice!
One last piece of the puzzle. When the user logs out, we should delete the token.
Inside HomeScreen
:
logOut = async () => {await setToken('');this.props.navigation.navigate('Login');};render() {const { users, userLoadingErrorMessage } = this.state;return (<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}><Text>HomeScreen</Text>{users.map((user) => (<Text key={user.email}>{user.email}</Text>))}{userLoadingErrorMessage ? (<Text>{userLoadingErrorMessage}</Text>) : null}<Button title="Log out" onPress={this.logOut} /></View>);}
HomeScreen is the best screen
Now that we have a proper login flow, we should take the user straight to the HomeScreen when they open the app. Then, if the getUsers
request fails due to an expired token, they'll be taken to log in again.
Inside App.js
, set the initialRouteName
to 'Home'. Reload your app, and you should see the HomeScreen. If you're logged in, the users will load. If you're not (after previously clicked 'Log out', then reloading the app), you'll be taken to the login screen.
A subtle bug
Perhaps without noticing, we've now introduced a problem. Try the following flow:
- Load the app, then click 'Log out'
- Reload the app. You'll be taken the LoginScreen.
- Log in again.
Notice what happens: the users don't load! That's because we're relying on componentDidMount
, and the component has already mounted.
Here's what we actually want: every time the user navigates to the HomeScreen
, if the users have not been loaded, we should load them.
To do so, we need to subscribe to an event in the react-navigation
lifecyle: the didFocus
event, indicating the user has just arrived back at this screen.
Read more about this event + listener here.
Fortunately, the modification is an easy one:
componentDidMount() {this.didFocusSubscription = this.props.navigation.addListener('didFocus',() => {if (!this.state.hasLoadedUsers) {this.loadUsers();}},);}componentWillUnmount() {this.didFocusSubscription.remove();}
We're still using componentDidMount
, but now using it to register a subscription to the navigation event. Try the following flow above, and see how loading the users now works! Nice.
One last fix: when a user logs out and logs in as a different user, they will see the previous user's data. We can make a small change to fix that:
logOut = async () => {this.setState({ hasLoadedUsers: false, users: [] });await setToken("");this.props.navigation.navigate("Login");};
Forms
The funny thing about our login form... is that it's not actually a form.
Let's add some proper fields for accepting the user's email and password, both for creating an account and logging in.
Create a new folder, src/forms/
. Inside that folder, create a file called EmailForm.js
.
This component is going to be very simple. It allows the user to input an email and password. We can use it for both LoginScreen
and CreateAccountScreen
.
EmailForm.js
:
import React, { useState } from "react";import { ScrollView, StyleSheet, TextInput, Button, Text } from "react-native";import { setToken } from "../api/token";const EmailForm = ({ buttonText, onSubmit, children, onAuthentication }) => {const [email, onChangeEmail] = useState("");const [password, onChangePassword] = useState("");const [errorMessage, setErrorMessage] = useState("");const submit = () => {onSubmit(email, password).then(async (res) => {await setToken(res.auth_token);onAuthentication();}).catch((res) => setErrorMessage(res.error));};return (<ScrollView contentContainerStyle={styles.container}><TextInputstyle={styles.input}onChangeText={(text) => onChangeEmail(text)}value={email}keyboardType="email-address"/><TextInputstyle={styles.input}onChangeText={(text) => onChangePassword(text)}value={password}secureTextEntry/><Button title={buttonText} onPress={submit} />{errorMessage ? <Text>{errorMessage}</Text> : null}{children}</ScrollView>);};const styles = StyleSheet.create({container: {flex: 1,alignItems: "center",justifyContent: "center",},input: {height: 40,width: 300,borderColor: "gray",borderWidth: 1,marginTop: 20,},});export default EmailForm;
There's a lot happening here. Let's go through it:
- This component manages the email, password, and error message state.
- When the user submits the form by clicking the button, we call whatever
onSubmit
prop was passed in. - Then, we take care of setting the auth token from the result.
- Lastly, we call the
onAuthentication
prop, which will handle navigation.
We use a ScrollView
so the user can keep the input fields in view when the keyboard is activated.
Here's it in action with LoginScreen
:
import React, { useState } from "react";import { View, Text, Button } from "react-native";import { login } from "../api/mock";import EmailForm from "../forms/EmailForm";const LoginScreen = ({ navigation }) => {return (<EmailFormbuttonText="Log in"onSubmit={login}onAuthentication={() => navigation.navigate("Home")}><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/></EmailForm>);};export default LoginScreen;
You can see this vastly simplifies our original component. We can still pass in the link to go to the CreateAccountScreen
as a child prop, but most of the logic is moved out of this component, which is a good thing.
CreateAccountScreen
is equally clean:
import React, { useState } from "react";import { View, Text, Button } from "react-native";import { createAccount } from "../api/mock";import EmailForm from "../forms/EmailForm";const CreateAccount = ({ navigation }) => {return (<EmailFormbuttonText="Sign up"onSubmit={createAccount}onAuthentication={() => navigation.navigate("Home")}><Buttontitle="Back to log in"onPress={() => navigation.navigate("Login")}/></EmailForm>);};export default CreateAccount;
As promised, these screens now have as little shared code as is reasonable.
Hooking up to a real API (optional)
This is the last step: to get rid of our mock methods and use a real API.
For this part, I'll be relying on the API I built and deployed in my Rails authentication tutorial. I recommend you go through that tutorial first.
Setting the URL
The first thing we'll need to do is to include our API URL in the project. You probably don't want to expose this publicly on Git, so let's create a secrets.js
file in the project root, and include that in our .gitignore
.
secrets.js
:
export const API_URL = <YOUR URL>
We can now import this to our request files.
Replacing our mocks
Make three new files in src/api/
: fetch.js
, authentication.js
, and users.js
.
We'll use fetch.js
to handle interactions with the fetch
request API:
import { API_URL } from "../../secrets";import { getToken } from "./token";const getHeaders = async () => {const token = await getToken();const headers = {Accept: "application/json","Content-Type": "application/json",};if (token) {headers.Authorization = `Bearer ${token}`;}return headers;};export const post = async (destination, body) => {const headers = await getHeaders();const result = await fetch(`${API_URL}${destination}`, {method: "POST",headers,body: JSON.stringify(body),});console.log(result);if (result.ok) {return await result.json();}throw { error: result.status };};export const get = async (destination) => {const headers = await getHeaders();const result = await fetch(`${API_URL}${destination}`, {method: "GET",headers,});if (result.ok) {return await result.json();}throw { error: result.status };};
The above creates helper methods for making POST
and GET
requests using the token, if available.
In authentication.js
, we'll handle login/signup requests:
import { post } from "./fetch";export const login = (email, password) => {return post("/users/login", {user: { email, password },});};export const createAccount = (email, password) => {return post("/users", {user: { email, password },});};
And lastly, in users.js
, we'll handle requests related to users:
import { get } from "./fetch";export const getUsers = (email, password) => {return get("/users");};
Now, the easy part. In our three screens, replace the import from ../api/mock
with the relevant file.
LoginScreen
looks like this, just a changed import:
import React, { useState } from "react";import { View, Text, Button } from "react-native";import { login } from "../api/authentication";import EmailForm from "../forms/EmailForm";const LoginScreen = ({ navigation }) => {return (<EmailFormbuttonText="Log in"onSubmit={login}onAuthentication={() => navigation.navigate("Home")}><Buttontitle="Create account"onPress={() => navigation.navigate("CreateAccount")}/></EmailForm>);};export default LoginScreen;
HomeScreen
is a bit different, in that our users
array will no longer be nested:
import React from 'react';import { View, Text, Button } from 'react-native';import { getUsers } from '../api/users';import { setToken } from '../api/token';export default class HomeScreen extends React.Component {state = { users: [], hasLoadedUsers: false, userLoadingErrorMessage: '' };loadUsers() {this.setState({ hasLoadedUsers: false, userLoadingErrorMessage: '' });getUsers().then((users) => { // See herethis.setState({hasLoadedUsers: true,users,});}).catch(this.handleUserLoadingError);}
I made one small change to EmailForm
, to allow the display of a general error message if the error is not request-related:
.catch((res) => {if (res && res.error) {setErrorMessage(res.error);}setErrorMessage('Something went wrong.');});
Other than that, your code should work out of the box! Try signing in, loading users, and creating an account.
Conclusion
You did it! You built a full authentication workflow for a React Native application. This is something you'll have to do for most apps, but now you can refer back to this guide (or your project code) to get it done as fast as possible.
Thanks for reading! If this guide has been helpful, consider subscribing below. I have a lot more content planned along these lines.
This article now has a sequel! Check out Sexy forms with React Native to learn how to take the above and make it better-looking and better-engineered.