Adding functionality to the app (Part 6)

React Native deep linking

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.

A screenshot of the RegisterScreen

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.

2 comments

Leave a Reply

Your email address will not be published. Required fields are marked *