Implementing auth flow in React-native with Zustand

·

10 min read

In case you don't know about Zustand. Zustand is a minimalistic state management library that is easy to set up and use. Almost like the Context-API kinda.

Nevertheless, I'll be showing you the implementation of setting up a Global auth store for your react-native application.

Note: The main goal of the article is to provide the auth flow. You might notice components and file imports inside the code that are irrelevant and not utilized in this article. So do not just blindly copy-paste, please.

Basic setup

Assuming you've already created the react-native project.

Install the required dependencies:

npm i react-native-keychain zustand axios yup

Here is the project structure on my end after some tweaking. You can name the folders however you like.

  • useAuthStore.ts will be the Zustand store that contains auth-related methods.

  • useAuthFacade.ts is an optional custom-hook. It just contains a friendly export for the useAuthStore.

  • interfaces.ts contains interfaces to be used in the store. (Typescript thing)

Zustand Store Setup

Here is the code inside the store folder.

// store/auth/useAuthStore.ts

import { createWithEqualityFn } from 'zustand/traditional'
import { InitialStateProps } from './interfaces';
import * as Keychain from 'react-native-keychain';
import AxiosConfig from '@app/utils/axiosConfig';

const initialState = {
    user: null,
    loading: false,
    error: null,
    success: false,
    accessToken: null
}

const useAuthStore = createWithEqualityFn<InitialStateProps>()((set) => {
    const getAccessToken = async () => {
        const credentials = await Keychain.getInternetCredentials('accessToken');

        if (credentials) {
            return credentials;
        } else {
            return null;
        }
    }

    const getUser = async () => {
        const user = await Keychain.getInternetCredentials('user');

        if (user) {
            return user;
        } else {
            return null;
        }
    }

    getAccessToken().then((initialAccessToken) => {
        set((state) => ({ ...state, accessToken: initialAccessToken }));
    });

    getUser().then((userData) => {
        set((state) => ({ ...state, user: userData }));
    });

    return {
        ...initialState,

        resetStore: () => {
            set((state) => ({ ...state, loading: false, success: false, error: null }));
        },

        login: async ({ phone, password }) => {
            set((state) => ({ ...state, loading: true }));

            AxiosConfig.get('/login', {
                headers: { phone_number: phone, pw: password }
            })
                .then(async (loginResponse) => {
                    const userData = loginResponse.data;

                    await Keychain.setInternetCredentials(
                        'user', 'user', JSON.stringify(userData.user)
                    );
                    await Keychain.setInternetCredentials(
                        'accessToken', 'accessToken', userData.access_token
                    );

                    set((state) => ({
                        ...state,
                        error: null,
                        success: true,
                        user: userData.user,
                        accessToken: userData.access_token
                    }));
                })
                .catch((errorResponse) => {
                    set((state) => ({
                        ...state,
                        success: false,
                        error: errorResponse?.response?.data?.message || errorResponse.message
                    }));
                })
                .finally(() => {
                    set((state) => ({ ...state, loading: false }));
                });
        },

        register: async ({
            firstName, lastName, phone, password
        }) => {
            set((state) => ({ ...state, loading: true }));

            AxiosConfig.post('/signup', {
                first_name: firstName,
                last_name: lastName,
                phone_number: phone,
                password: password
            })
                .then(() => {
                    set((state) => ({ ...state, error: null, success: true }));
                })
                .catch((errorResponse) => {
                    set((state) => ({
                        ...state,
                        success: false,
                        error: errorResponse.response?.data?.message || errorResponse.message
                    }));
                })
                .finally(() => {
                    set((state) => ({ ...state, loading: false }));
                });
        },

        logout: async () => {
            await Keychain.resetInternetCredentials('user');
            await Keychain.resetInternetCredentials('accessToken');

            set(initialState);
        }
    }
});

export default useAuthStore;

Here is what happening in the above code:

  • Created a Zustand store with createWithEqualityFn (Simple create method can be used also but I was facing some issues with it).

  • Introduced methods that will interact with API endpoints i-e, login, register, logout, resetStore.

  • Using react-native-keychain to store the credentials inside secure mobile storage.

  • AxiosConfig contains our API endpoint defaults. I'll show you the code afterward.

// store/auth/useAuthFacade.ts

import { shallow } from "zustand/shallow"
import useAuthStore from "./useAuthStore"

export const useAuthFacade = () => {
    const {
        user,
        loading,
        error,
        success,
        login,
        logout,
        register,
        resetStore,
        accessToken
    } = useAuthStore(
        (state) => ({
            user: state.user,
            loading: state.loading,
            error: state.error,
            login: state.login,
            register: state.register,
            success: state.success,
            resetStore: state.resetStore,
            accessToken: state.accessToken,
            logout: state.logout
        }),
        shallow
    )

    return {
        user,
        loading,
        error,
        success,
        login,
        register,
        resetStore,
        accessToken,
        logout
    }
}

export const useAuthStoreAxiosState = () => {
    const { accessToken, user, logout } = useAuthStore.getState();

    return { accessToken, user, logout };
}

Here is what happening in the above code:

  • Grabbing all the states from useAuthStore and exporting them in useAuthFacade helper hook so that they can be simply imported into our screens.

  • useAuthStoreAxiosState is here to remove a warning from react-native. Require cycles are allowed, but can result in uninitialized values. Just to break the cycle. Warning was in the AxiosConfig.ts file.

// store/auth/interfaces.ts

export interface RegisterProps {
  firstName: string,
  lastName: string,
  phone: string,
  password: string
}

export interface LoginProps {
  phone: string,
  password: string
}

export interface AccessTokenProps {
  password: string,
  server: string,
  storage: string,
  username: string
}

export interface InitialStateProps {
  loading: boolean,
  error: string | null,
  user: object | null,
  success: boolean,
  accessToken: AccessTokenProps | null,
  login: ({ phone, password }: LoginProps) => void,
  register: ({ firstName, lastName, phone, password }: RegisterProps) => void,
  resetStore: () => void,
  logout: () => void
}

These are the interfaces used in the above Store files for Typescript purposes.

In case you are not using Typescript, you can just ignore all the Typescript stuff and it will be fine.

Axios Configuration

/* eslint-disable @typescript-eslint/no-explicit-any */
import { useAuthStoreAxiosState } from '@app/store/auth/useAuthFacade';
import axios from 'axios';

const instance = axios.create({
    baseURL: 'http://localhost:4000/api',
    withCredentials: true
});

instance.interceptors.request.use(
    (config) => {
        if (config) {
            const { accessToken, user } = useAuthStoreAxiosState();

            if (accessToken && user) {
                const userID = JSON.parse((user as any).password).id;

                config.headers["Authorization"] = `Bearer ${accessToken.password}`;
                config.headers["Content-Type"] = "application/json";
                config.headers["user_id"] = userID || null;
            }

            return config;
        }

    },
    (err) => Promise.reject(err)
);

instance.interceptors.response.use((response) => {
    return response;
}, async (error) => {
    if (error?.response !== null) {
        if (error.response.status === 403) {
            useAuthStoreAxiosState().logout();
        }
    }

    return Promise.reject(error);
});

export default instance;

Things to note in Axios configuration:

  • Define a base URL for the Axios instance that contains your API endpoint.

  • Axios request interceptor uses the accessToken and user from the useAuthStoreAxiosState() (💡Remember we created this function inside the useAuthFacade.tsx!). Then pass these inside the headers for our protected routes on the backend.

  • Axios response interceptor to check if the server responds with a 403 status that depends on your server. In my case 403 means Access-Token is expired and it needs to be Logged Out and that is happening from the Zustand store as well.

Auth Screens

// screens/registerScreen.tsx

import * as Yup from 'yup';
import styles from './registerScreen.module.scss';
import { Button } from '@app/components/button';
import { CustomText } from '@app/components/customText';
import { useEffect, useState } from 'react';
import { SafeAreaView, Text } from 'react-native';
import { getCountryByCca2 } from 'react-native-international-phone-number';
import { CountryPhoneInputField } from './countryPhoneInputField';
import { InputField } from '@app/components/inputField';
import { Spinner } from '@app/components/spinner';
import { showMessage } from 'react-native-flash-message';
import { useAuthFacade } from '@app/store/auth/useAuthFacade';
import { Container } from '@app/components/container';

const firstNameValidator = Yup.string()
    .max(50, 'First name must be at most 50 characters long')
    .matches(/^[a-zA-Z\s]+$/, 'First name should contain only letters and spaces')
    .required('First name is required');

const lastNameValidator = Yup.string()
    .max(50, 'Last name must be at most 50 characters long')
    .matches(/^[a-zA-Z\s]+$/, 'Last name should contain only letters and spaces')
    .required('Last name is required');

const passwordValidator = Yup.string()
    .required('Password is required')
    .min(8, 'Password must contain 8 or more characters with at least one of each: uppercase, lowercase, number and special')
    .matches(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/,
        'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'
    );

const validationSchema = Yup.object().shape({
    firstName: firstNameValidator,
    lastName: lastNameValidator,
    password: passwordValidator,
});

export const RegisterScreen = ({ navigation }) => {
    const { register, loading, success, error, resetStore } = useAuthFacade();
    const [selectedCountry, setSelectedCountry] = useState(getCountryByCca2('CA'));
    const [validationErrors, setValidationErrors] = useState([]);
    const [phoneNumber, setPhoneNumber] = useState('');
    const [currentStep, setCurrentStep] = useState(1);
    const [fields, setFields] = useState({
        firstName: '',
        lastName: '',
        password: '',
        confirmPassword: ''
    })

    function handleSelectedCountry(country) {
        setSelectedCountry(country);
    }

    const handleProfileInfoChange = (text: string, field: string) => {
        setFields({ ...fields, [field]: text });
        setValidationErrors([]);
    }

    const handleContinue = () => {
        setCurrentStep(2);
    }

    const createAccount = () => {
        validationSchema
            .validate(fields, { abortEarly: false })
            .then(async () => {
                setValidationErrors([]);

                if (fields.password !== fields.confirmPassword) {
                    setValidationErrors(['Passwords do not match']);

                    return;
                }

                register({ ...fields, phone: phoneNumber });
            })
            .catch((error) => setValidationErrors(error.errors));
    }

    useEffect(() => {
        resetStore();

        if (success) {
            showMessage({ message: 'User Registered Successfully', type: 'success' });

            setTimeout(() => navigation.replace('LoginScreen'), 2000);
        }

        if (error) {
            showMessage({ message: error, type: 'success' });
        }
    }, [error, navigation, resetStore, success])

    return (
        <SafeAreaView>
            <Container>
                {
                    currentStep === 1 && (
                        <>
                            <CustomText style={styles.createAccountText}>Create a new account</CustomText>
                            <CustomText style={styles.phoneNumberLabel}>Phone Number</CustomText>
                            <CountryPhoneInputField
                                inputValue={phoneNumber}
                                handleInputValue={(phoneNumber) => setPhoneNumber(phoneNumber)}
                                handleSelectedCountry={handleSelectedCountry}
                                selectedCountry={selectedCountry}
                            />
                            <Button
                                style={{ marginTop: 40 }}
                                variant='contained'
                                disabled={phoneNumber.length === 0}
                                onPress={handleContinue}>
                                Continue
                            </Button>
                        </>
                    )
                }
                {
                    currentStep === 2 && (
                        <>
                            <CustomText style={styles.createAccountText}>Finish account creation</CustomText>
                            <InputField
                                label='First Name'
                                value={fields.firstName}
                                onChangeText={(text) => handleProfileInfoChange(text, 'firstName')}
                            />
                            <InputField
                                label='Last Name'
                                value={fields.lastName}
                                onChangeText={(text) => handleProfileInfoChange(text, 'lastName')}
                            />
                            <InputField
                                label='Password'
                                value={fields.password}
                                onChangeText={(text) => handleProfileInfoChange(text, 'password')}
                                secureTextEntry={true}
                            />
                            <InputField
                                label='Confirm Password'
                                value={fields.confirmPassword}
                                onChangeText={(text) => handleProfileInfoChange(text, 'confirmPassword')}
                                secureTextEntry={true}
                            />
                            {
                                validationErrors.length > 0 && (
                                    <Text style={styles.validationError}>{validationErrors[0]}</Text>
                                )
                            }
                            <Button
                                style={{ marginTop: 30 }}
                                variant='contained'
                                disabled={
                                    fields.firstName.length === 0 ||
                                    fields.lastName.length === 0 ||
                                    fields.password.length === 0 ||
                                    fields.confirmPassword.length === 0
                                }
                                onPress={createAccount}>
                                {
                                    loading
                                        ? <Spinner spinnerColor='#fff' backgroundColor='transparent' />
                                        : 'Create Account'
                                }
                            </Button>
                        </>
                    )
                }
            </Container>
        </SafeAreaView>
    );
}

Things to note in the above code:

  • Notice inside the useEffect() code, I am resetting the store states i-e,loading, error, and success state just to remove confusion in the Login and Register Screens so that we have a clean store from the start on these screens.

  • Yup validation is done for the entire form.

  • Also, the useFacade() is being utilized here. See how simple is the importing of states here.

  • Rest is just simple Form. Ignore the rest of the imports (CountryInputField and getCountryByCca2 etc). They don't matter for now.

// screens/loginScreen.tsx

import { Button } from '@app/components/button';
import { CustomText } from '@app/components/customText';
import { useEffect, useState } from 'react';
import { SafeAreaView, Text } from 'react-native';
import { InputField } from '@app/components/inputField';
import { Container } from '@app/components/container';
import { showMessage } from 'react-native-flash-message';
import { Spinner } from '@app/components/spinner';
import { useAuthFacade } from '@app/store/auth/useAuthFacade';
import * as Yup from 'yup';
import styles from './loginScreen.module.scss';

const validationSchema = Yup.object().shape({
    phoneNumber: Yup.string().required('Phone number is required'),
    password: Yup.string().min(8).required('Password is required'),
});

export const LoginScreen = () => {
    const { login, loading, success, error, resetStore } = useAuthFacade();
    const [validationErrors, setValidationErrors] = useState([]);
    const [fields, setFields] = useState({
        phoneNumber: '',
        password: '',
    })

    const handleFormChange = (text: string, field: string) => {
        setFields({ ...fields, [field]: text });
        setValidationErrors([]);
    }

    const loginHandler = () => {
        validationSchema
            .validate(fields, { abortEarly: false })
            .then(async () => {
                setValidationErrors([]);

                login({
                    phone: fields.phoneNumber,
                    password: fields.password
                });
            })
            .catch((error) => setValidationErrors(error.errors));
    }

    useEffect(() => {
        resetStore();

        if (success) {
            showMessage({ message: 'Logged In Successfully', type: 'success' });
        }

        if (error) {
            showMessage({ message: error, type: 'danger' });
        }
    }, [error, resetStore, success]);

    return (
        <SafeAreaView>
            <Container>
                <CustomText style={styles.pageTitle}>Sign in to your account</CustomText>
                <InputField
                    label='Phone Number'
                    value={fields.phoneNumber}
                    onChangeText={(text) => handleFormChange(text, 'phoneNumber')}
                />
                <InputField
                    label='Password'
                    value={fields.password}
                    onChangeText={(text) => handleFormChange(text, 'password')}
                    secureTextEntry={true}
                />
                {
                    validationErrors.length > 0 && (
                        <Text style={styles.validationError}>
                            {validationErrors[0]}
                        </Text>
                    )
                }
                <Button
                    style={{ marginTop: 30 }}
                    variant='contained'
                    disabled={ fields.phoneNumber.length === 0 || fields.password.length === 0}
                    onPress={loginHandler}>
                    {
                        loading
                            ? <Spinner spinnerColor='#fff' backgroundColor='transparent' />
                            : 'Sign In'
                    }
                </Button>
            </Container>
        </SafeAreaView>
    );
}

The above Login screen is almost similar to the Register Screen so nothing special here.

import 'react-native-gesture-handler';
import React, { useState } from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { LoginScreen } from '@app/screens/loginScreen';
import { RegisterScreen } from '@app/screens/registerScreen';
import { TabNavigator } from './tabNavigator';
import { useAuthFacade } from '@app/store/auth/useAuthFacade';
import BackArrow from '@app/assets/back-arrow.svg';
import Logo from '@app/assets/main-logo.svg';

const Stack = createStackNavigator();

const noHeaderOptions = {
    headerShown: false,
    cardStyle: { paddingHorizontal: 0 }
}

export const App = () => {
    const [showSplash, setShowSplash] = useState(false);
    const { accessToken } = useAuthFacade();

    return (
        <>
            <NavigationContainer>
                <Stack.Navigator
                    initialRouteName='HomeScreen'
                    screenOptions={{
                        headerTitleAlign: 'center',
                        headerStyle: {
                            backgroundColor: '#fff',
                            height: 120
                        },
                        cardStyle: {
                            backgroundColor: '#fff',
                            paddingHorizontal: 8
                        },
                        headerShadowVisible: false,
                        headerBackImage: () => <BackArrow />,
                        headerBackTitleVisible: false,
                        headerTitle: () => <Logo width={130} />
                    }}>
                    {
                        accessToken !== null
                            ? (
                                <>
                                    <Stack.Screen options={noHeaderOptions} name="HomeScreen" component={TabNavigator} />
                                </>
                            )
                            : (
                                <>
                                    <Stack.Screen name="LoginScreen" component={LoginScreen} />
                                    <Stack.Screen name="RegisterScreen" component={RegisterScreen} />
                                </>
                            )
                    }
                </Stack.Navigator>
            </NavigationContainer>
        </>
    );
};

export default App;

Finally here is our App file with all the routes. Things to note here:

  • Imported the accessToken from useAuthFacade and used it to conditionally render HomeScreen or AuthScreens.

  • Even if you close the app and open it again, accessToken persists in your device and Zustand uses that for the conditional render here again.

Final words

Having experience with Redux Toolkit, Zustand for me is a quick and clean library to use. I haven't gotten much deeper into it yet for bigger projects but it's something that can be used at the component level and not as a whole global store (Just my thoughts 🫠 which can change in the future). Apart from some use cases like auth or themes etc.

Anyway, there are lots of debates on whether can Zustand be used like Redux as a global state manager. That depends on project requirements, scalability and the structure of your code. You can search about the Zustand vs Redux debates on Reddit anyway 😜. We are not here for that. Peace ✌️