In the previous part we’ve created a basic mobile app using React Native and the Expo framework. We’ve added login functionality and setup the router for our app. In this part we’re going add a registration screen and handle user activation using deeplinks.
The user registration screen
Let’s start by creating a user registration screen. This will be a very basic form for this series with just 4 input fields and a button. We’ll need the user’s first name, last name, email address and a password for the account.
Create src/screens/public/RegisterScreen.tsx
with the following content.
import * as React from "react";
import {Button, HStack, Link, VStack, Text} from "native-base";
import {useNavigation} from "@react-navigation/native";
import {useRegisterMutation} from "../../redux/api/user";
import ErrorHelper from "../../api/helpers/ErrorHelper";
import Input from "../../components/form/Input";
import PublicLayout from "../../components/layout/PublicLayout";
export default function RegisterScreen() {
const [firstName, setFirstName] = React.useState('');
const [lastName, setLastName] = React.useState('');
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const navigation = useNavigation();
const [register, { isLoading }] = useRegisterMutation();
const [isRegistered, setIsRegistered] = React.useState(false);
const [errors, setErrors] = React.useState<{[key: string]: string}>({});
const handleLogin = async () => {
try {
await register({firstName, lastName, email, password}).unwrap();
setIsRegistered(true);
} catch (e) {
ErrorHelper.processErrors(e, setErrors);
}
};
return (
<PublicLayout title="Register an account">
{isRegistered && (
<Text>Your account is successfully created. Please check your email for instructions on how to activate your account.</Text>
)}
{!isRegistered && (
<VStack space={3} mt="5">
<Input label="First name" value={firstName} onChangeText={(value) => setFirstName(value)} error={errors.firstName} />
<Input label="Last name" value={lastName} onChangeText={(value) => setLastName(value)} error={errors.lastName} />
<Input label="Email address" value={email} onChangeText={(value) => setEmail(value)} error={errors.email} />
<Input label="Password" type="password" value={password} onChangeText={(value) => setPassword(value)} error={errors.password} />
<Button disabled={isLoading} mt="2" colorScheme="red" onPress={handleLogin}>
{isLoading ? <Text color="white">Loading...</Text> : <Text color="white">Sign up</Text>}
</Button>
</VStack>
)}
<HStack mt="6" justifyContent="center">
<Link _text={{ fontSize: 'sm', fontWeight: 'medium', color: 'red.500' }} onPress={() => navigation.navigate('Main')}>
Back to login
</Link>
</HStack>
</PublicLayout>
);
}
Code language: JavaScript (javascript)
You might notice a few references in the code to components and classes like PublicLayout
, Input
and ErrorHelper
we did not cover yet. Don’t worry, I’ll explain everything.
PublicLayout
To ensure all the screens look and feel the same I’ve created a PublicLayout
component which will be used by all public screens. The code for this component is pretty easy, but can be adjusted / changed however you see fit.
I’ve created this component in src/components/layout/PublicLayout.tsx
with this code:
import {Box, Heading} from "native-base";
import * as React from "react";
type PublicLayoutProps = {
children: React.ReactNode;
title: string;
};
export default function PublicLayout({children, title}: PublicLayoutProps) {
return (
<Box safeArea flex={1} p="2" py="8" w="90%" mx="auto">
<Heading size="lg" fontWeight="bold">
{title}
</Heading>
{children}
</Box>
);
}
Code language: JavaScript (javascript)
As you can see there is a lot of room for improvement, but we’ll leave that up to you.
Input
For the form’s input fields we’ve created a wrapper component to ensure layout consistency throughout the app. It also makes error handling a lot easier with the ErrorHelper
class I’ll show you soon.
The code for this wrapper component is located in src/components/form/Input.tsx
.
import {FormControl, Input as BaseInput, IInputProps} from "native-base";
import * as React from "react";
type InputProps = IInputProps & {
label: string;
type?: string | undefined;
error?: string | undefined;
};
export default function Input({label, type, error, ...props}: InputProps) {
return (
<FormControl isInvalid={!!error}>
<FormControl.Label>
{label}
</FormControl.Label>
<BaseInput type={type || 'text'} {...props} />
<FormControl.ErrorMessage>{error}</FormControl.ErrorMessage>
</FormControl>
);
}
Code language: JavaScript (javascript)
This wrapper contains the markup for the Input
fields in our application as well as the label and the error messages. When we want to show an error message we can just add the message to the props
of our Input
component and it will be displayed automatically.
ErrorHelper
The ErrorHelper
makes it easier for us to handle the errors returned by our API. It will convert the response to a useful error object which we can use in our application to show the correct error messages to our users. Let’s check out the code which is located in src/api/helpers/ErrorHelper.ts
.
import Toast from 'react-native-root-toast';
/**
* Helper class for API Errors
*/
class ErrorHelper
{
/**
* @param error
* @param setValidationErrors
*/
public processErrors(error: any, setValidationErrors: Function): void
{
switch(error.status) {
case 'FETCH_ERROR':
case 'PARSING_ERROR':
case 'CUSTOM_ERROR':
Toast.show(`There was a network error. Please try again later. ${error.error}`, {
duration: Toast.durations.LONG,
});
break;
case 401:
Toast.show('Invalid credentials specified.', {duration: Toast.durations.LONG});
break;
case 403:
Toast.show('You do not have access to this resource.', {duration: Toast.durations.LONG});
break;
case 422:
this.processValidationErrors(error.data, setValidationErrors);
break;
default:
if (error.data && error.data['hydra:description']) {
Toast.show(error.data['hydra:description'], {duration: Toast.durations.LONG});
}
console.error(error.status);
console.error(error.data);
console.error(error);
break;
}
}
/**
* @param data
* @param setValidationErrors
* @private
*/
private processValidationErrors(data: any, setValidationErrors: Function): void
{
const errors: any = {};
data.violations.forEach((violation: any) => {
errors[violation.propertyPath] = violation.message;
});
setValidationErrors(errors);
}
}
export default new ErrorHelper();
Code language: TypeScript (typescript)
We will use this class whenever we need to handle errors from our API to display a Toast
message to our user or show form validation errors, as shown in RegisterScreen.tsx
.
To show the toast messages in a consistent way across iOS and Android we’re using a library called react-native-root-toast
. In order for this library to work we’ll need to wrap our application in a RootSiblingParent
component by editing our App.tsx
file and adding the highlighted lines.
import React from 'react';
import {NativeBaseProvider} from "native-base";
import {Provider} from "react-redux";
import {store} from "./src/redux/store";
import {NavigationContainer} from '@react-navigation/native';
import Router from "./src/router/Router";
import {RootSiblingParent} from 'react-native-root-siblings';
export default function App() {
return (
<Provider store={store}>
<RootSiblingParent>
<NavigationContainer linking={linking}>
<NativeBaseProvider>
<Router/>
</NativeBaseProvider>
</NavigationContainer>
</RootSiblingParent>
</Provider>
);
}
Code language: JavaScript (javascript)
Update router
To incorporate our RegistrationScreen
we’ll need to add it to our Router
. This ensures our application is aware of the registration screen we’ve just created.
Open src/router/Router.tsx
and add the following.
import * as React from "react";
import LoginScreen from "../screens/public/LoginScreen";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import {useAppSelector} from "../hooks/useAppSelector";
import {LoginStatus} from "../redux/state/auth";
import DashboardScreen from "../screens/private/DashboardScreen";
import RegisterScreen from "../screens/public/RegisterScreen";
export default function Router() {
const Stack = createNativeStackNavigator();
const loginStatus = useAppSelector(state => state.auth.status);
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
gestureEnabled: true,
}}
>
{loginStatus === LoginStatus.LOGGED_IN && (
<Stack.Screen name="Dashboard" component={DashboardScreen} />
)}
{loginStatus === LoginStatus.NOT_LOGGED_IN && (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</>
)}
</Stack.Navigator>
);
}
Code language: JavaScript (javascript)
Now we can update the ‘Sign up’ link on our LoginScreen
to link to our RegistrationScreen
. Edit src/screens/public/LoginScreen.tsx
to update the link.
import * as React from "react";
import { Button, FormControl, HStack, Input, Link, VStack, Text} from "native-base";
import {useLoginMutation} from "../../redux/api/auth";
import {useNavigation} from "@react-navigation/native";
import ErrorHelper from "../../api/helpers/ErrorHelper";
import PublicLayout from "../../components/layout/PublicLayout";
export default function LoginScreen() {
const navigation = useNavigation();
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [login, { isLoading }] = useLoginMutation();
const handleLogin = async () => {
try {
await login({email, password}).unwrap();
} catch (e) {
ErrorHelper.processErrors(e, () => {});
}
};
return (
<PublicLayout title="Sign in">
<VStack>
// ... FORM REMOVED FOR BREVITY
<HStack mt="6" justifyContent="center">
<Link _text={{ fontSize: 'sm', fontWeight: 'medium', color: 'red.500'}} onPress={() => navigation.navigate('Register')}>
Sign Up
</Link>
</HStack>
</VStack>
</PublicLayout>
);
}
Code language: JavaScript (javascript)
I’ve also changed the LoginScreen
to make use of our PublicLayout
component and our ErrorHelper
to ensure consistency.
Update Redux API
In order to actually register a user we’ll need to call our register
endpoint of our API. To do this we’ll need to add some endpoints to our RTK Query configuration.
Let’s create src/redux/api/user.ts
with the following content.
import api from "./api";
import RegisterRequest from "../../api/requests/user/RegisterRequest";
const userApi = api.injectEndpoints({
endpoints: build => ({
// Public endpoints
register: build.mutation<boolean, RegisterRequest>({
query: (request) => ({
url: '/api/public/users/register',
method: 'POST',
body: request,
}),
transformResponse: (query, meta): boolean => {
if (!meta!.response) {
return false;
}
return 204 === meta!.response.status;
}
}),
}),
});
export const { useRegisterMutation } = userApi;
export default userApi;
Code language: TypeScript (typescript)
We’ll also need to create the RegisterRequest
type used in the build.mutation<boolean, RegisterRequest>
definition. Create src/api/requests/user/RegisterRequest.ts
to add this type.
type RegisterRequest = {
firstName: string,
lastName: string,
email: string,
password: string,
}
export default RegisterRequest;
Code language: JavaScript (javascript)
This will create a working RegisterScreen
which allows for users to sign up and use our app.
Account activation
In order for users to be able to log in they’ll need to activate their account. The API will send an email to the user when we call the register
endpoint. In this email is a link which will open our app on the ActivateAccountScreen
so let’s make sure this screen exists.
Create src/screens/public/ActivateAccountScreen.tsx
and add the following content.
import * as React from "react";
import PublicLayout from "../../components/layout/PublicLayout";
import {Text} from "native-base";
import {useActivateAccountMutation} from "../../redux/api/user";
import ErrorHelper from "../../api/helpers/ErrorHelper";
import Toast from "react-native-root-toast";
export default function ActivateAccountScreen({route, navigation}) {
const [activateAccount, { isLoading, isError }] = useActivateAccountMutation();
React.useEffect(() => {
// Run this function immediately
(async () => {
try {
await activateAccount({token: route.params.token}).unwrap();
navigation.navigate('Login');
} catch(e) {
ErrorHelper.processErrors(e, (errors) => {
if (errors.token) {
Toast.show(errors.token, {duration: Toast.durations.LONG});
}
});
}
})();
}, []);
return (
<PublicLayout title="Activate Account">
{isLoading && <Text>Activating your account...</Text>}
{isError && <Text>An error occurred while activating your account!</Text>}
</PublicLayout>
);
}
Code language: JavaScript (javascript)
This will immediately call our activate-account
endpoint with the provided token and it shows a message to the user. After the account is activated successfully the user is redirected to the LoginScreen
where the user can log in.
In order to use useActivateAccountMutation
we’ll need to add the endpoint configuration to src/redux/api/user.ts
.
import api from "./api";
import RegisterRequest from "../../api/requests/user/RegisterRequest";
import ActivateAccountRequest from "../../api/requests/user/ActivateAccountRequest";
const userApi = api.injectEndpoints({
endpoints: build => ({
// ... REMOVED FOR BREVITY
activateAccount: build.mutation<boolean, ActivateAccountRequest>({
query: (request) => ({
url: '/api/public/users/activate',
method: 'POST',
body: request,
}),
transformResponse: (query, meta): boolean => {
if (!meta!.response) {
return false;
}
return 204 === meta!.response.status;
}
}),
}),
});
export const { useRegisterMutation, useActivateAccountMutation } = userApi;
export default userApi;
Code language: TypeScript (typescript)
And the corresponding src/api/requests/user/ActivateAccountRequest.ts
.
type ActivateAccountRequest = {
token: string,
}
export default ActivateAccountRequest;
Code language: TypeScript (typescript)
Deep linking in apps
Now that the AccountActivationScreen
is created we’ll need to configure our app to open on the AccountActivationScreen
when the activation link from the email is clicked. This is a process called deep linking.
In order to open https
links in custom apps we’ll need to tell the operating system (iOS or Android) to open our app instead of the browser.
iOS
For iOS we’ll need to add a file apple-app-site-association
to our website in the .well-known
folder with the following content.
{
"applinks": {
"apps": [], // This is usually left empty, but still must be included
"details": [{
"appID": "ABCDEF.com.woutercarabain.stockportfolio",
"paths": ["*"]
}]
}
}
Code language: JSON / JSON with Comments (json)
Replace the appID
with your own AppID.
Android
For android we’ll have to add an Intent filter for our app. To do this open app.json
in the root of the app project and add the following to the android
section.
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "stocks.woutercarabain.com",
"pathPrefix": "*"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
Code language: JSON / JSON with Comments (json)
This will open our app on Android when the user clicks a link to https://stocks.woutercarabain.com/*.
React Navigation setup
Now that our app is ready for deep linking we’ll need to configure React Navigation to open the correct screen when the app is opened using a deep link. In order to do this we’ll need install expo-linking
using the expo install expo-linking
command. When it’s installed we’ll need to add some configuration to our NavigationContainer
in App.tsx
.
import React from 'react';
import {NativeBaseProvider} from "native-base";
import {Provider} from "react-redux";
import {store} from "./src/redux/store";
import {NavigationContainer} from '@react-navigation/native';
import Router from "./src/router/Router";
import {RootSiblingParent} from 'react-native-root-siblings';
import * as Linking from "expo-linking";
const prefix = Linking.createURL('/');
export default function App() {
const linking = {
prefixes: [prefix, 'https://stocks.woutercarabain.com'],
config: {
screens: {
ActivateAccount: 'activate-account',
}
}
};
return (
<Provider store={store}>
<RootSiblingParent>
<NavigationContainer linking={linking}>
<NativeBaseProvider>
<Router/>
</NativeBaseProvider>
</NavigationContainer>
</RootSiblingParent>
</Provider>
);
}
Code language: JavaScript (javascript)
The prefix is used when you’ve specified an app prefix in app.json. In order to make our application work with the Expo Go app and stand alone we’ll use the Linking.createURL('/')
statement to fetch the used prefix.
We create a linking
object to store our linking settings for the NavigationContainer
. We’ll add our prefix as well as our domain. Then we’ll map the paths of the urls to the correct screens using the config
entry. In this case https://stocks.woutercarabain.com/activate-account will open the ActivateAccountScreen
and perform the actual account activation. The query parameters in the url will be available in the screen in the route.params
object.
Test deep linking
Because we’re using the Expo Go app to test and develop our app, we cannot use our domain name to test the deep links. This is because the Intent Filters are only installed when the app is installed on your device. When we use the Expo Go app, our own app is not installed – it’s merely rendered by the Expo Go app.
In order to test deep linking we can use the exp://
prefix to trigger the deep linking. On Android you can run the following command with your device connected to adb
(via USB or using adb connect
):
adb shell am start -W -a android.intent.action.VIEW -d "exp://YOUR-EXPO-URL/--/activate-account?token=TOKENSTRING"
Replace YOUR-EXPO-URL
with the url shown in the Metro bundler interface and replace TOKENSTRING
with the JWT Token generated by the API.
If done correctly the app should open on your phone or the emulator on the ActivateAccountScreen
and perform the actual account activation.
Final words
In this part we’ve added a register and account activation screen. We’ve also added some components and classes to make our life as developers easier.
Nice work! Do you have a GitHub link for this?
I found it, thanks!