Building a token-based authentication solution with React's Context API and Typescript
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:
- Password Authentication
- Two-Factor Authentication (2FA)
- Single Sign-On (SSO)
- CAPTCHAs
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 🙌