Building a token-based authentication solution with React's Context API and Typescript

Building a token-based authentication solution with React's Context API and Typescript

ยท

9 min read

When it comes to building a modern web application there are various challenges a developer must overcome and authentication is one of them. There are various authentication methods available today with their specific use cases to ensure secure access to our applications and services.

Here are some authentication methods you must have encountered:

This article will focus on the Password Authentication method as it's a common one developers implement in their projects.

Prerequisites

To get the best out of this article you shouldn't have a problem with the following:

  • ES6 JavaScript
  • TypeScript basics
  • React and some of its features like Hooks and Context API
  • Basic usage of Next.js

Disclaimer

The approach to authentication in this article isn't suitable with 3rd party authentication providers. If you're going to use them please make sure to use their official SDKs.

Let's get started

Set up the project by installing Next.js with TypeScript.

yarn create next-app --typescript

The next step is to install the axios package for making HTTP requests.

yarn add axios

This is all the installation necessary to start coding. Now let's take a step back and reason through how this should work.

Some of the requirements are:

  • A user should be able to log in.
  • A user should be able to log out.
  • A user's auth state should be persisted even after the page reloads.

The API makes use of a token for accessing a protected resource which in our case is the user's personal data and the token will be persisted in localstorage.

However, this approach to storing tokens has several pitfalls, you can get a better understanding by reading this article.

We'll be making use of React's Context API for managing the auth state and finally, we'll create a hook for our components to access the auth context.

Back to coding ๐Ÿ‘จโ€๐Ÿ’ป

Let's create the necessary type definitions we'll need and store them in typings/index.ts

export interface User {
  id: string;
  email: string;
  name: string;
}

export interface LoginResponse {
  user: User;
  token: string;
}

export interface IAuthContext {
  state: IAuthState;
  actions: IAuthAction;
}

export interface IAuthState {
  user: User | null | undefined;
  initialLoading: boolean;
  isLoggingIn: boolean;
  loginError: string;
}

export interface IAuthAction {
  login: (email: string, password: string) => void;
  logout: () => void;
}

export enum AuthActionType {
  INIT_LOGIN = "INIT_LOGIN",
  LOGIN_SUCCESSFUL = "LOGIN_SUCCESSFUL",
  LOGIN_FAILED = "LOGIN_FAILED",
  INIT_FETCH_USER_DATA = "INIT_FETCH_USER_DATA",
  FETCH_USER_DATA_SUCCESSFUL = "FETCH_USER_DATA_SUCCESSFUL",
  FETCH_USER_DATA_FAILED = "FETCH_USER_DATA_FAILED",
  LOGOUT = "LOGOUT",
}

export interface AuthAction {
  type: AuthActionType;
  payload?: {
    user?: User;
    error?: string;
  };
}

Create the functions for working with the Authentication API

The next step is to add the code we'll need to interact with our authentication API. Create a new file api/index.ts and add the code below

import axios from "axios";
import { LoginResponse, User } from "../typings";

// create an axios instance
const authApi = axios.create({
  baseURL: "http://restapi.adequateshop.com/api",
  headers: {
    "Content-Type": "application/json",
  },
});

export const login = async (
  email: string,
  password: string
): Promise<LoginResponse | null> => {
  try {
    const data = JSON.stringify({ email, password });
    const response = await authApi.post("/authaccount/login", data);
    if (response && response.status === 200) {
      const responseData = response.data.data;
      if (responseData) {
        return {
          user: {
            id: responseData.Id,
            email: responseData.Email,
            name: responseData.Name,
          },
          token: responseData.Token,
        };
      } else {
        throw new Error(response.data.message || "Login failed");
      }
    }
    return null;
  } catch (error: Error | any) {
    throw new Error(error.message || "Login failed");
  }
};

export const getUserData = async (userId: string): Promise<User | null> => {
  try {
    const token = localStorage.getItem("token") || "";
    const response = await authApi.get(`/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    if (response && response.status === 200) {
      const responseData = response.data;
      if (responseData) {
        return {
          id: responseData.id,
          email: responseData.email,
          name: responseData.name,
        };
      } else {
        throw new Error(response.data.message || "Failed to fetch user data");
      }
    }
    return null;
  } catch (error: Error | any) {
    throw new Error(error.message || "Failed to fetch user data");
  }
};

Set up the Authentication Provider

The next step is to create an auth provider that makes use of React's Context API to pass down the auth state and actions for child components to consume. There are two custom hooks for making use of the auth state and actions so we don't have to write it manually every time we need it. Create a new file components/auth/index.tsx and add the code below.

import {
  createContext,
  FC,
  ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useReducer,
} from "react";
import { getUserData, login as loginFn } from "../../api";
import {
  IAuthState,
  IAuthContext,
  AuthAction,
  AuthActionType,
} from "../../typings";

// initial state for the useReducer hook
const initialState: IAuthState = {
  user: null,
  initialLoading: false,
  isLoggingIn: false,
  loginError: "",
};

// initial value for the auth context
const initialContext: IAuthContext = {
  state: {
    user: null,
    initialLoading: false,
    isLoggingIn: false,
    loginError: "",
  },
  actions: {
    login: () => undefined,
    logout: () => undefined,
  },
};

// reducer function for returning the appropriate state after
// a pre-defined action is dispatched
const reducer = (state: IAuthState, action: AuthAction): IAuthState => {
  const { type, payload } = action;
  switch (type) {
    case AuthActionType.INIT_FETCH_USER_DATA:
      return {
        ...state,
        initialLoading: true,
      };

    case AuthActionType.FETCH_USER_DATA_SUCCESSFUL:
      return {
        ...state,
        initialLoading: false,
        user: payload?.user,
      };

    case AuthActionType.FETCH_USER_DATA_FAILED:
      return {
        ...state,
        initialLoading: false,
        user: null,
      };

    case AuthActionType.INIT_LOGIN:
      return {
        ...state,
        isLoggingIn: true,
      };

    case AuthActionType.LOGIN_SUCCESSFUL:
      return {
        ...state,
        user: payload?.user,
        isLoggingIn: false,
        loginError: "",
      };

    case AuthActionType.LOGIN_FAILED:
      return {
        ...state,
        user: null,
        isLoggingIn: false,
        loginError: payload?.error as string,
      };

    case AuthActionType.LOGOUT:
      return {
        ...state,
        user: null,
      };

    default:
      return state;
  }
};

const AuthContext = createContext<IAuthContext>(initialContext);

const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // fetch the data of a user on initial page load
  // to restore their session if there's a token and user id
  useEffect(() => {
    const fetchUserData = async () => {
      try {
        const userId = localStorage.getItem("userId");
        const token = localStorage.getItem("token");
        if (userId && token) {
          dispatch({ type: AuthActionType.INIT_FETCH_USER_DATA });
          const user = await getUserData(userId);
          if (user) {
            dispatch({
              type: AuthActionType.FETCH_USER_DATA_SUCCESSFUL,
              payload: { user },
            });
          } else {
            dispatch({
              type: AuthActionType.FETCH_USER_DATA_FAILED,
            });
          }
        }
      } catch (error: Error | any) {
        dispatch({
          type: AuthActionType.FETCH_USER_DATA_FAILED,
        });
      }
    };

    fetchUserData();
  }, []);

  // used the useCallback hook to prevent the function from being recreated after a re-render
  const login = useCallback(async (email: string, password: string) => {
    try {
      dispatch({ type: AuthActionType.INIT_LOGIN });
      const loginResponse = await loginFn(email, password);
      if (loginResponse) {
        const { user, token } = loginResponse;
        // store the token in localStorage
        localStorage.setItem("token", token);

        // store the user's id in localStorage
        localStorage.setItem("userId", user.id);

        // complete a successful login process
        dispatch({ type: AuthActionType.LOGIN_SUCCESSFUL, payload: { user } });
        // go to the home page
        window.location.href = "/";
      } else {
        dispatch({
          type: AuthActionType.LOGIN_FAILED,
          payload: { error: "Login failed" },
        });
      }
    } catch (error: Error | any) {
      dispatch({
        type: AuthActionType.LOGIN_FAILED,
        payload: { error: error.message || "Login failed" },
      });
    }
  }, []);

  // used the useCallback hook to prevent the function from being recreated after a re-render
  const logout = useCallback(() => {
    dispatch({ type: AuthActionType.LOGOUT });
    localStorage.removeItem("token");
    localStorage.removeItem("userId");
  }, []);

  // stored the auth context value in useMemo hook to recalculate
  // the value only when necessary
  const value = useMemo(
    () => ({ state, actions: { login, logout } }),
    [login, logout, state]
  );

  return (
    <AuthContext.Provider value={value}>
      {state.initialLoading ? <div>Loading...</div> : children}
    </AuthContext.Provider>
  );
};

// hook for accessing the auth state
export const useAuthState = () => {
  const { state } = useContext(AuthContext);
  return state;
};
// hook for accessing the auth actions
export const useAuthActions = () => {
  const { actions } = useContext(AuthContext);
  return actions;
};

export default AuthProvider;

Make use of the Auth Provider

Currently, our components can't make use of the Auth Provider since it's not part of the application, and the best place to put it is at the top of the component tree so it can be available to all the components below it. Open pages/_app.tsx and modify the code to look like this

import "../styles/globals.css";
import type { AppProps } from "next/app";
import AuthProvider from "../components/auth";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  );
}

export default MyApp;

Create the Login Page

Let's create a login page to make use of the login functionality we created. create a new file pages/login.tsx and add the code below

import type { NextPage } from "next";
import { useState } from "react";
import { useAuthActions, useAuthState } from "../components/auth";

const Login: NextPage = () => {
  const { loginError, isLoggingIn } = useAuthState();
  const { login } = useAuthActions();
  const [state, setState] = useState({ email: "", password: "" });

  const handleLogin = () => {
    if (!(state.email && state.password)) return;
    const { email, password } = state;
    login(email, password);
  };

  return (
    <div>
      <h1>Login</h1>
      <div>{loginError && `Error: ${loginError}`}</div>
      <form onSubmit={(evt) => evt.preventDefault()}>
        <div>
          <input
            type="email"
            placeholder="Email"
            onChange={(evt) => setState({ ...state, email: evt.target.value })}
          />
        </div>
        <div>
          <input
            type="password"
            placeholder="Password"
            onChange={(evt) =>
              setState({ ...state, password: evt.target.value })
            }
          />
        </div>
        <div>
          <button onClick={!isLoggingIn ? handleLogin : undefined}>
            {isLoggingIn ? "Loading..." : "Log in"}
          </button>
        </div>
      </form>
    </div>
  );
};

export default Login;

Create the home page

The home page is very simple, it displays a greeting with the name of the user and a button to log out if they're logged in and a link to the login page when logged out. Open pages/index.tsx and modify the code too like the one below

import type { NextPage } from "next";
import Link from "next/link";
import { useAuthActions, useAuthState } from "../components/auth";

const Home: NextPage = () => {
  const { user } = useAuthState();
  const { logout } = useAuthActions();

  return (
    <div>
      <h1>Hello {user?.name || "Guest"}</h1>
      {user ? (
        <button onClick={logout}>Logout</button>
      ) : (
        <Link href="/login">Log in</Link>
      )}
    </div>
  );
};

export default Home;

Add an account page

To fulfill the purpose of authentication we have to create a page that only authenticated users can access.

First, let's add a link to the account page on our home page pages/index.tsx

import type { NextPage } from "next";
import Link from "next/link";
import { useAuthActions, useAuthState } from "../components/auth";

const Home: NextPage = () => {
  const { user } = useAuthState();
  const { logout } = useAuthActions();

  return (
    <div>
      <h1>Hello {user?.name || "Guest"}</h1>
      {user ? (
        <div>
          <div style={{ marginBottom: "10px" }}>
            <Link href="/account">
              <a style={{ textDecoration: "underline" }}>View Account Info</a>
            </Link>
          </div>
          <button onClick={logout}>Logout</button>
        </div>
      ) : (
        <Link href="/login">Log in</Link>
      )}
    </div>
  );
};

export default Home;

Finally, create a new file pages/account.tsx, and add the code below

import type { NextPage } from "next";
import { useRouter } from "next/router";
import { useAuthState } from "../components/auth";

const Account: NextPage = () => {
  const router = useRouter();
  const { user } = useAuthState();

  // navigate to the home page if unauthenticated
  if (!user) {
    router.push("/");
  }

  return (
    <div>
      <div>Name: {user?.name}</div>
      <div>User Id: {user?.id}</div>
      <div>Email: {user?.email}</div>
    </div>
  );
};

export default Account;

Cheers ๐Ÿ‘, we've been able to implement an authentication solution for our application in a very simple and predictable way.

Conclusion

I hope this has been useful to you? and If there are any issues or opinions you'd like to share please feel free to drop a comment. Thanks for reading ๐Ÿ™Œ

ย