React Query is a server state management library. This article will teach you how to implement JWT Authentication and Authorization with React Query, Axios interceptors, Typescript, and React-Hook-Form.
More practice:
- React, Material UI and React Hook Form: Login and Signup Forms
- React, RTK Query, React Hook Form and Material UI – Image Upload
- React + Redux Toolkit: JWT Authentication and Authorization
- React.js + Redux Toolkit: Refresh Tokens Authentication
Here is what you’ll learn:
- JWT Authentication and Authorization with React, React Query, and Axios Interceptors
- How to make HTTP requests with React Query and Axios
- How to perform form validations with React-Hook-Form and Zod
- How to protect private routes with a middleware
- How to refresh an access token with Axios interceptors
- How to verify the registered user’s email address
What and Why React Query?
React Query is often described as a server state management library for React. To be more precise, it eliminates the need to hand-write data fetching, caching, cache invalidation, polling, synchronizing, and updating server state logic in your React applications.
Over the last couple of years, the React community has come to realize that most state management libraries work really well with client states but do not have any built-in tool to manage server states.
Nowadays, almost every web application needs to fetch data from a server in order to display it in the UI. They also need to update the data, send the updates to the server, and keep the data stored on the server in sync with the cached data on the client.
This pattern is made more complicated when the need arises to implement more advanced features used in recent applications:
- Managing memory and garbage collection of server state
- Combining multiple requests for the same data into a single request
- Managing cache lifetimes (knowing when data is “out of date“)
- Requesting fresh data from the server when the cached data is “out of date“
- Tracking loading state in order to display loading spinners in the UI
At the time of writing this article, there have been a lot of changes in the React ecosystem:
- Redux aka Redux Toolkit now comes bundled with an optional data fetching and caching tool called RTK Query.
You can read React + Redux Toolkit: JWT Authentication and Authorization to better understand how to work with Redux Toolkit and RTK Query. - React Query now supports RESTful and GRAPHQL API requests
React Query, Typescript, and Axios Overview
We will build a React.js, Material UI, and Typescript client before adding React Query and Axios to implement the JWT Authentication logic.
The registration and login form validation will be done with React-hook-form and Zod.
Here are some screenshots:
– The user lands on the home page and clicks on the “SIGNUP” button to register for an account.
-On the registration page, the user provides the required credentials and makes a POST request to the server by clicking the “SIGN UP” button.
-The server then validates the credentials and sends a verification code to the user’s email.
-The user opens the email and clicks on the “Verify Your Account” button.
-The user is immediately redirected to the email verification page where the verification code will be pre-filled in the text field.
Next, the user clicks on the “VERIFY EMAIL” button to make a GET request with the code to the server.
-The server validates the verification code and sends a success message to the frontend application.
The frontend app then receives the success message and redirects the user to the login page where he is required to provide the credentials used for the account registration.
-After the user clicks the log-in button, a POST request will be made by React Query to the server.
-Also, you can check the browser to see the cookies returned by the server after the user is authenticated.
React Query and Axios JWT Authentication Flow
To authenticate a user, we are going to call five routes:
- React Query Axios POST request
api/auth/register
– to register the new user - React Query Axios GET request
api/auth/verifyemail
– to verify the user’s email address - React Query Axios POST request
api/auth/login
– to authenticate the registered user. - React Query Axios GET request
api/auth/logout
– to logout the user - React Query Axios GET request
api/auth/refresh
– to retrieve a new access token.
Below is a diagram describing the account registration flow:
JWT Authentication flow with React Query, Axios, Typescript, and a backend API.
You can find a step-by-step implementation of the backend APIs in the following tutorials:
- Node.js + TypeScript + MongoDB: JWT Authentication
- Node.js + TypeScript + MongoDB: JWT Refresh Token
- Build Golang gRPC Server and Client: Access & Refresh Tokens
- Node.js + Prisma + PostgreSQL: Access & Refresh Tokens
- Golang & MongoDB: JWT Authentication and Authorization
- API with Node.js + PostgreSQL + TypeORM: JWT Authentication
Setup React Query, MUI, Axios, and Typescript Project
First things first, let’s generate a basic React.js application.
Run this command in your terminal to create a React.js boilerplate application in a react-query-axios
directory.
yarn create react-app react-query-axios --template typescript
# or
npx create-react-app react-query-axios --template typescript
Install Material UI, React-Hook-Form, and React Router Dom
Run the following command to install Material UI v5, React-Hook-Form, and React-Router-Dom:
Material UI v5
yarn add @mui/material @emotion/react @emotion/styled @mui/lab
# or
npm install @mui/material @emotion/react @emotion/styled @mui/lab
React-Hook-Form and Zod
yarn add react-hook-form @hookform/resolvers zod
# or
npm install react-hook-form @hookform/resolvers zod
React-Router-Dom and some other dependencies
yarn add react-toastify react-router-dom react-cookie
# or
npm install react-toastify react-router-dom react-cookie
Install React Query and Axios
React Query
yarn add @tanstack/react-query @tanstack/react-query-devtools
# or
npm install @tanstack/react-query @tanstack/react-query-devtools
Note: React Query is compatible with React v16.8+ and works great with ReactDOM and React Native.
To configure React Query with React.js, we need to wrap the root app with the QueryClientProvider component and provide it with the QueryClient instance.
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<react.strictmode>
<queryclientprovider client="{queryClient}">
<app />
<reactquerydevtools initialisopen="{false}" />
</queryclientprovider>
</react.strictmode>
);
Out of the box, React Query considers cached data as stale and the stale queries are prefetched automatically in the background when:
- New instances of the query mount
- The window is refocused
- The network is reconnected.
- The query is optionally configured with a re-fetch interval.
To change the default behaviors, you can configure your queries both globally and per query using the config object.
You can change the default behaviors globally by passing defaultOptions
config to the QueryClient()
. For example:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnmount: false,
refetchOnReconnect: false,
retry: 1,
staleTime: 5 * 1000,
},
},
});
refetchOnWindowFocus
: If"always"
, the query will always re-fetch in the background on window focus.refetchOnmount
: Iftrue
, the query will re-fetch on mount if the cached data is stale.refetchOnReconnect
: Defaults totrue
. Iftrue
, the query will re-fetch on reconnect if the cached data is staleretry
: If set to anumber
, failed queries will retry until the failed query count reaches that number.staleTime
: The time in milliseconds after data becomes stale.
Axios
yarn add axios
# or
npm install axios
Define the Request and Response Types
After all the configuration above, it’s now time to define the data types with Typescript.
Create and export the request and response interfaces in a src/api/types.ts
file.
src/api/types.ts
export interface IUser {
name: string;
email: string;
role: string;
_id: string;
id: string;
createdAt: string;
updatedAt: string;
__v: number;
}
export interface GenericResponse {
status: string;
message: string;
}
export interface ILoginResponse {
status: string;
access_token: string;
}
export interface IUserResponse {
status: string;
data: {
user: IUser;
};
}
Create the API Services with Axios
Before creating the Axios authentication services, we need to configure Axios to use some default configurations.
Create an src/api/authApi.ts
file and add the code below to create the Axios instance.
src/api/authApi.ts
import axios from 'axios';
const BASE_URL = 'http://localhost:8000/api/';
export const authApi = axios.create({
baseURL: BASE_URL,
withCredentials: true,
});
authApi.defaults.headers.common['Content-Type'] = 'application/json';
// (...)
Next, let’s define the logic to refresh the access token when it expires.
import axios from 'axios';
import { LoginInput } from '../pages/login.page';
import { RegisterInput } from '../pages/register.page';
import { GenericResponse, ILoginResponse, IUserResponse } from './types';
// Axios instance and default configuration
export const refreshAccessTokenFn = async () => {
const response = await authApi.get<ILoginResponse>('auth/refresh');
return response.data;
};
authApi.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
const errMessage = error.response.data.message as string;
if (errMessage.includes('not logged in') && !originalRequest._retry) {
originalRequest._retry = true;
await refreshAccessTokenFn();
return authApi(originalRequest);
}
return Promise.reject(error);
}
);
On the server, there are many scenarios where I returned a 401 Unauthorized error so it doesn’t make sense to use that for the if
condition in the response interceptor.
However, the server will return a 401 status code along with a message indicating that the access token wasn’t included in the request.
So, if the message contains “not logged in” then it means the server did not find the access token in the cookies or the authorization header.
After the Axios response interceptor checks the error message and discovers that it contains “not logged in“, it will immediately evoke the refreshAccessTokenFn()
service to refresh the access token.
For security reasons, the access and refresh tokens are stored in HTTPOnly cookies in the browser to prevent hackers from using Javascript to access them.
Note: You need to set
withCredentials: true
in the Axios config for the browser to include the cookies along with the requests.
Now, let’s add the services to perform the JWT authentication against the API server:
refreshAccessTokenFn
: Makes a GET request to retrieve a new access token.signUpUserFn
: Makes a POST request to register the new user.loginUserFn
: Makes a POST request to sign in the registered user.verifyEmailFn
: Makes a GET request to verify the user’s email.logoutUserFn
: Makes a GET request to log out the user.getMeFn
: Makes a GET request to retrieve the authenticated user’s credentials.
src/api/authApi.ts
import axios from 'axios';
import { LoginInput } from '../pages/login.page';
import { RegisterInput } from '../pages/register.page';
import { GenericResponse, ILoginResponse, IUserResponse } from './types';
const BASE_URL = 'http://localhost:8000/api/';
const authApi = axios.create({
baseURL: BASE_URL,
withCredentials: true,
});
authApi.defaults.headers.common['Content-Type'] = 'application/json';
export const refreshAccessTokenFn = async () => {
const response = await authApi.get<ILoginResponse>('auth/refresh');
return response.data;
};
authApi.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
const errMessage = error.response.data.message as string;
if (errMessage.includes('not logged in') && !originalRequest._retry) {
originalRequest._retry = true;
await refreshAccessTokenFn();
return authApi(originalRequest);
}
return Promise.reject(error);
}
);
export const signUpUserFn = async (user: RegisterInput) => {
const response = await authApi.post<GenericResponse>('auth/register', user);
return response.data;
};
export const loginUserFn = async (user: LoginInput) => {
const response = await authApi.post<ILoginResponse>('auth/login', user);
return response.data;
};
export const verifyEmailFn = async (verificationCode: string) => {
const response = await authApi.get<GenericResponse>(
`auth/verifyemail/${verificationCode}`
);
return response.data;
};
export const logoutUserFn = async () => {
const response = await authApi.get<GenericResponse>('auth/logout');
return response.data;
};
export const getMeFn = async () => {
const response = await authApi.get<IUserResponse>('users/me');
return response.data;
};
Create a React Context API to Manage Client State
To manage the client state, I decided to use React Context API because I felt using Redux Toolkit will be an overkill for this project.
The new React Context API allows us to avoid prop drilling by using a top-level state as our source of truth.
Also, using the context API will give our components the ability to communicate and share data at different levels of the application.
Create a src/context/index.ts
file and add the following code snippets.
src/context/index.ts
import React from 'react';
import { IUser } from '../api/types';
type State = {
authUser: IUser | null;
};
type Action = {
type: string;
payload: IUser | null;
};
type Dispatch = (action: Action) => void;
const initialState: State = {
authUser: null,
};
type StateContextProviderProps = { children: React.ReactNode };
const StateContext = React.createContext<
{ state: State; dispatch: Dispatch } | undefined
>(undefined);
const stateReducer = (state: State, action: Action) => {
switch (action.type) {
case 'SET_USER': {
return {
...state,
authUser: action.payload,
};
}
default: {
throw new Error(`Unhandled action type`);
}
}
};
const StateContextProvider = ({ children }: StateContextProviderProps) => {
const [state, dispatch] = React.useReducer(stateReducer, initialState);
const value = { state, dispatch };
return (
<StateContext.Provider value={value}>{children}</StateContext.Provider>
);
};
const useStateContext = () => {
const context = React.useContext(StateContext);
if (context) {
return context;
}
throw new Error(`useStateContext must be used within a StateContextProvider`);
};
export { StateContextProvider, useStateContext };
Create Components with Material UI
Loading Spinner Component
src/components/FullScreenLoader.tsx
import { Box, CircularProgress, Container } from '@mui/material';
const FullScreenLoader = () => {
return (
<Container sx={{ height: '95vh' }}>
<Box
display='flex'
alignItems='center'
justifyContent='center'
sx={{ height: '100%' }}
>
<CircularProgress />
</Box>
</Container>
);
};
export default FullScreenLoader;
Navigation Header Component
src/components/Header.tsx
import { AppBar, Box, Container, Toolbar, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { useStateContext } from '../context';
import { useMutation } from '@tanstack/react-query';
import { logoutUserFn } from '../api/authApi';
const LoadingButton = styled(_LoadingButton)`
padding: 0.4rem;
color: #222;
font-weight: 500;
&:hover {
transform: translateY(-2px);
}
`;
const Header = () => {
const navigate = useNavigate();
const stateContext = useStateContext();
const user = stateContext.state.authUser;
const { mutate: logoutUser, isLoading } = useMutation(
async () => await logoutUserFn(),
{
onSuccess: (data) => {
window.location.href = '/login';
},
onError: (error: any) => {
if (Array.isArray(error.response.data.error)) {
error.data.error.forEach((el: any) =>
toast.error(el.message, {
position: 'top-right',
})
);
} else {
toast.error(error.response.data.message, {
position: 'top-right',
});
}
},
}
);
const onLogoutHandler = async () => {
logoutUser();
};
return (
<>
<AppBar position='static' sx={{ backgroundColor: '#fff' }}>
<Container maxWidth='lg'>
<Toolbar>
<Typography
variant='h6'
onClick={() => navigate('/')}
sx={{ cursor: 'pointer', color: '#222' }}
>
CodevoWeb
</Typography>
<Box display='flex' sx={{ ml: 'auto' }}>
{!user && (
<>
<LoadingButton
sx={{ mr: 2 }}
onClick={() => navigate('/register')}
>
SignUp
</LoadingButton>
<LoadingButton onClick={() => navigate('/login')}>
Login
</LoadingButton>
</>
)}
{user && (
<>
<LoadingButton
loading={isLoading}
onClick={() => navigate('/profile')}
>
Profile
</LoadingButton>
<LoadingButton onClick={onLogoutHandler} loading={isLoading}>
Logout
</LoadingButton>
</>
)}
</Box>
</Toolbar>
</Container>
</AppBar>
</>
);
};
export default Header;
Custom TextField component
src/components/FormInput.tsx
import {
FormHelperText,
Typography,
FormControl,
Input as _Input,
InputProps,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { FC } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
const Input = styled(_Input)`
background-color: white;
padding: 0.4rem 0.7rem;
margin-bottom: 0.5rem;
`;
type IFormInputProps = {
name: string;
label: string;
} & InputProps;
const FormInput: FC<IFormInputProps> = ({ name, label, ...otherProps }) => {
const {
control,
formState: { errors },
} = useFormContext();
return (
<Controller
control={control}
defaultValue=''
name={name}
render={({ field }) => (
<FormControl fullWidth sx={{ mb: 2 }}>
<Typography
variant='body2'
sx={{ color: '#2363eb', mb: 1, fontWeight: 500 }}
>
{label}
</Typography>
<Input
{...field}
fullWidth
disableUnderline
sx={{ borderRadius: '1rem' }}
error={!!errors[name]}
{...otherProps}
/>
<FormHelperText error={!!errors[name]}>
{errors[name] ? errors[name].message : ''}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default FormInput;
Layout Component
src/components/layout.tsx
import { Outlet } from 'react-router-dom';
import Header from './Header';
const Layout = () => {
return (
<>
<Header />
<Outlet />
</>
);
};
export default Layout;
Authentication Guard Component
On the backend, apart from the access and refresh token cookies, we also returned a logged_in
cookie that can be accessed by React since it is not an HTTPOnly cookie.
Note: the
logged_in
cookie has the same lifespan as the access token cookie.
To protect private routes on the client, we need to create a RequireUser
guard middleware to check if the user is authenticated.
src/components/requireUser.tsx
import { useCookies } from 'react-cookie';
import { useQuery } from '@tanstack/react-query';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { getMeFn } from '../api/authApi';
import { useStateContext } from '../context';
import FullScreenLoader from './FullScreenLoader';
const RequireUser = ({ allowedRoles }: { allowedRoles: string[] }) => {
const [cookies] = useCookies(['logged_in']);
const location = useLocation();
const stateContext = useStateContext();
const {
isLoading,
isFetching,
data: user,
} = useQuery(['authUser'], getMeFn, {
retry: 1,
select: (data) => data.data.user,
onSuccess: (data) => {
stateContext.dispatch({ type: 'SET_USER', payload: data });
},
});
const loading = isLoading || isFetching;
if (loading) {
return <FullScreenLoader />;
}
return (cookies.logged_in || user) &&
allowedRoles.includes(user?.role as string) ? (
<Outlet />
) : cookies.logged_in && user ? (
<Navigate to='/unauthorized' state={{ from: location }} replace />
) : (
<Navigate to='/login' state={{ from: location }} replace />
);
};
export default RequireUser;
Authentication Middleware Guard
src/middleware/AuthMiddleware.tsx
import { useCookies } from 'react-cookie';
import { useQuery } from '@tanstack/react-query';
import { getMeFn } from '../api/authApi';
import { useStateContext } from '../context';
import FullScreenLoader from '../components/FullScreenLoader';
import React from 'react';
type AuthMiddlewareProps = {
children: React.ReactElement;
};
const AuthMiddleware: React.FC<AuthMiddlewareProps> = ({ children }) => {
const [cookies] = useCookies(['logged_in']);
const stateContext = useStateContext();
const query = useQuery(['authUser'], () => getMeFn(), {
enabled: !!cookies.logged_in,
select: (data) => data.data.user,
onSuccess: (data) => {
stateContext.dispatch({ type: 'SET_USER', payload: data });
},
});
if (query.isLoading && cookies.logged_in) {
return <FullScreenLoader />;
}
return children;
};
export default AuthMiddleware;
React Query Axios Typescript Login User
To perform CREATE, UPDATE, or DELETE operations against the backend API, we use React Query useMutation
hook.
const {
mutate: loginUser,
isLoading,
isError,
error,
isSuccess,
} = useMutation((userData: LoginInput) => loginUserFn(userData), {
onSuccess: () => {
query.refetch();
},
});
loginUserFn
is a function that performs an asynchronous task and returns a promise.onSuccess
is an optional callback function that will be evoked when the mutation is successful and receives the mutation’s result as a parameter.onError
is an optional callback function that will be evoked when the mutation encounters an error and gets the error as a parameter.
The object returned by evoking the useMutation
hook contains:
isLoading
: A boolean that indicates whether the mutation is currently executing.isSuccess
: A boolean that indicates whether the last mutation attempt was successful.isError
: A boolean that indicates whether the last mutation attempt ended in an error.mutate
: A function you can evoke to manually trigger the mutation.error
: An error object returned after the mutation encounters an error.data
: The last successfully resolved data for the mutation.
To make a GET request to the API, we use React Query useQuery
hook:
const {
isSuccess,
isLoading,
refetch,
isError,
error,
data
} = useQuery(['authUser'], getMeFn, {
enabled: false,
select: (data) => {},
retry: 1,
onSuccess: (data) => {},
onError: (error) => {},
});
getMeFn
is a function that performs an asynchronous task and returns a promise.authUser
(queryKey): A unique key used by React Query for managing the query cache.enabled
: A boolean that can be used to disable the query from automatically running.select
: A function that can be used to transform the data returned by the query.retry
: If set to anumber
, failed queries will retry until the failed query count reaches that number. If set totrue
, failed queries will retry infinitely.
Now, let’s create a React Typescript component to log in the registered user with React Query and Axios:
src/pages/login.page.tsx
import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getMeFn, loginUserFn } from '../api/authApi';
import { useStateContext } from '../context';
const LoadingButton = styled(_LoadingButton)`
padding: 0.6rem 0;
background-color: #f9d13e;
color: #2363eb;
font-weight: 500;
&:hover {
background-color: #ebc22c;
transform: translateY(-2px);
}
`;
const LinkItem = styled(Link)`
text-decoration: none;
color: #2363eb;
&:hover {
text-decoration: underline;
}
`;
const loginSchema = object({
email: string()
.min(1, 'Email address is required')
.email('Email Address is invalid'),
password: string()
.min(1, 'Password is required')
.min(8, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
});
export type LoginInput = TypeOf<typeof loginSchema>;
const LoginPage = () => {
const navigate = useNavigate();
const location = useLocation();
const from = ((location.state as any)?.from.pathname as string) || '/';
const methods = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
});
const stateContext = useStateContext();
// API Get Current Logged-in user
const query = useQuery(['authUser'], getMeFn, {
enabled: false,
select: (data) => data.data.user,
retry: 1,
onSuccess: (data) => {
stateContext.dispatch({ type: 'SET_USER', payload: data });
},
});
// API Login Mutation
const { mutate: loginUser, isLoading } = useMutation(
(userData: LoginInput) => loginUserFn(userData),
{
onSuccess: () => {
query.refetch();
toast.success('You successfully logged in');
navigate(from);
},
onError: (error: any) => {
if (Array.isArray((error as any).response.data.error)) {
(error as any).response.data.error.forEach((el: any) =>
toast.error(el.message, {
position: 'top-right',
})
);
} else {
toast.error((error as any).response.data.message, {
position: 'top-right',
});
}
},
}
);
const {
reset,
handleSubmit,
formState: { isSubmitSuccessful },
} = methods;
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitSuccessful]);
const onSubmitHandler: SubmitHandler<LoginInput> = (values) => {
// ? Executing the loginUser Mutation
loginUser(values);
};
return (
<Container
maxWidth={false}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#2363eb',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Typography
textAlign='center'
component='h1'
sx={{
color: '#f9d13e',
fontWeight: 600,
fontSize: { xs: '2rem', md: '3rem' },
mb: 2,
letterSpacing: 1,
}}
>
Welcome Back!
</Typography>
<Typography
variant='body1'
component='h2'
sx={{ color: '#e5e7eb', mb: 2 }}
>
Login to have access!
</Typography>
<FormProvider {...methods}>
<Box
component='form'
onSubmit={handleSubmit(onSubmitHandler)}
noValidate
autoComplete='off'
maxWidth='27rem'
width='100%'
sx={{
backgroundColor: '#e5e7eb',
p: { xs: '1rem', sm: '2rem' },
borderRadius: 2,
}}
>
<FormInput name='email' label='Email Address' type='email' />
<FormInput name='password' label='Password' type='password' />
<Typography
sx={{ fontSize: '0.9rem', mb: '1rem', textAlign: 'right' }}
>
<LinkItem to='/' style={{ color: '#333' }}>
Forgot Password?
</LinkItem>
</Typography>
<LoadingButton
variant='contained'
sx={{ mt: 1 }}
fullWidth
disableElevation
type='submit'
loading={isLoading}
>
Login
</LoadingButton>
<Typography sx={{ fontSize: '0.9rem', mt: '1rem' }}>
Need an account? <LinkItem to='/register'>Sign Up Here</LinkItem>
</Typography>
</Box>
</FormProvider>
</Box>
</Container>
);
};
export default LoginPage;
React Query Axios Register User
Let’s use Typescript, and React Query with Axios POST request to register a new user.
src/pages/register.page.tsx
import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { signUpUserFn } from '../api/authApi';
const LoadingButton = styled(_LoadingButton)`
padding: 0.6rem 0;
background-color: #f9d13e;
color: #2363eb;
font-weight: 500;
&:hover {
background-color: #ebc22c;
transform: translateY(-2px);
}
`;
const LinkItem = styled(Link)`
text-decoration: none;
color: #2363eb;
&:hover {
text-decoration: underline;
}
`;
const registerSchema = object({
name: string().min(1, 'Full name is required').max(100),
email: string()
.min(1, 'Email address is required')
.email('Email Address is invalid'),
password: string()
.min(1, 'Password is required')
.min(8, 'Password must be more than 8 characters')
.max(32, 'Password must be less than 32 characters'),
passwordConfirm: string().min(1, 'Please confirm your password'),
}).refine((data) => data.password === data.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
export type RegisterInput = TypeOf<typeof registerSchema>;
const RegisterPage = () => {
const navigate = useNavigate();
const methods = useForm<RegisterInput>({
resolver: zodResolver(registerSchema),
});
// ? Calling the Register Mutation
const { mutate, isLoading } = useMutation(
(userData: RegisterInput) => signUpUserFn(userData),
{
onSuccess(data) {
toast.success(data?.message);
navigate('/verifyemail');
},
onError(error: any) {
if (Array.isArray((error as any).response.data.error)) {
(error as any).response.data.error.forEach((el: any) =>
toast.error(el.message, {
position: 'top-right',
})
);
} else {
toast.error((error as any).response.data.message, {
position: 'top-right',
});
}
},
}
);
const {
reset,
handleSubmit,
formState: { isSubmitSuccessful },
} = methods;
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitSuccessful]);
const onSubmitHandler: SubmitHandler<RegisterInput> = (values) => {
// ? Execute the Mutation
mutate(values);
};
return (
<Container
maxWidth={false}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#2363eb',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Typography
textAlign='center'
component='h1'
sx={{
color: '#f9d13e',
fontSize: { xs: '2rem', md: '3rem' },
fontWeight: 600,
mb: 2,
letterSpacing: 1,
}}
>
Welcome to CodevoWeb!
</Typography>
<Typography component='h2' sx={{ color: '#e5e7eb', mb: 2 }}>
Sign Up To Get Started!
</Typography>
<FormProvider {...methods}>
<Box
component='form'
onSubmit={handleSubmit(onSubmitHandler)}
noValidate
autoComplete='off'
maxWidth='27rem'
width='100%'
sx={{
backgroundColor: '#e5e7eb',
p: { xs: '1rem', sm: '2rem' },
borderRadius: 2,
}}
>
<FormInput name='name' label='Full Name' />
<FormInput name='email' label='Email Address' type='email' />
<FormInput name='password' label='Password' type='password' />
<FormInput
name='passwordConfirm'
label='Confirm Password'
type='password'
/>
<Typography sx={{ fontSize: '0.9rem', mb: '1rem' }}>
Already have an account?{' '}
<LinkItem to='/login'>Login Here</LinkItem>
</Typography>
<LoadingButton
variant='contained'
sx={{ mt: 1 }}
fullWidth
disableElevation
type='submit'
loading={isLoading}
>
Sign Up
</LoadingButton>
</Box>
</FormProvider>
</Box>
</Container>
);
};
export default RegisterPage;
React Query Axios Verify User’s Email
Next, let’s create a React Typescript component with React Query and Axios to verify the registered user’s email address.
src/pages/verifyemail.page.tsx
import { Box, Container, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { object, string, TypeOf } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import FormInput from '../components/FormInput';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { LoadingButton as _LoadingButton } from '@mui/lab';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { verifyEmailFn } from '../api/authApi';
const LoadingButton = styled(_LoadingButton)`
padding: 0.6rem 0;
background-color: #f9d13e;
color: #2363eb;
font-weight: 500;
&:hover {
background-color: #ebc22c;
transform: translateY(-2px);
}
`;
const verificationCodeSchema = object({
verificationCode: string().min(1, 'Verification code is required'),
});
export type VerificationCodeInput = TypeOf<typeof verificationCodeSchema>;
const EmailVerificationPage = () => {
const { verificationCode } = useParams();
const methods = useForm<VerificationCodeInput>({
resolver: zodResolver(verificationCodeSchema),
});
// ? API Login Mutation
const { mutate: verifyEmail, isLoading } = useMutation(
(verificationCode: string) => verifyEmailFn(verificationCode),
{
onSuccess: (data) => {
toast.success(data?.message);
navigate('/login');
},
onError(error: any) {
if (Array.isArray((error as any).data.error)) {
(error as any).data.error.forEach((el: any) =>
toast.error(el.message, {
position: 'top-right',
})
);
} else {
toast.error((error as any).data.message, {
position: 'top-right',
});
}
},
}
);
const navigate = useNavigate();
const {
reset,
handleSubmit,
formState: { isSubmitSuccessful },
} = methods;
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitSuccessful]);
useEffect(() => {
if (verificationCode) {
reset({ verificationCode });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSubmitHandler: SubmitHandler<VerificationCodeInput> = ({
verificationCode,
}) => {
// ? Executing the verifyEmail Mutation
verifyEmail(verificationCode);
};
return (
<Container
maxWidth={false}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#2363eb',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Typography
textAlign='center'
component='h1'
sx={{
color: '#f9d13e',
fontWeight: 600,
fontSize: { xs: '2rem', md: '3rem' },
mb: 2,
letterSpacing: 1,
}}
>
Verify Email Address
</Typography>
<FormProvider {...methods}>
<Box
component='form'
onSubmit={handleSubmit(onSubmitHandler)}
noValidate
autoComplete='off'
maxWidth='27rem'
width='100%'
sx={{
backgroundColor: '#e5e7eb',
p: { xs: '1rem', sm: '2rem' },
borderRadius: 2,
}}
>
<FormInput name='verificationCode' label='Verification Code' />
<LoadingButton
variant='contained'
sx={{ mt: 1 }}
fullWidth
disableElevation
type='submit'
loading={isLoading}
>
Verify Email
</LoadingButton>
</Box>
</FormProvider>
</Box>
</Container>
);
};
export default EmailVerificationPage;
Create Some Useful Pages with React and Material UI
Home Page
src/pages/home.page.tsx
import { Box, Container, Typography } from '@mui/material';
const HomePage = () => {
return (
<Container
maxWidth={false}
sx={{ backgroundColor: '#2363eb', minHeight: '100vh', pt: '5rem' }}
>
<Box
maxWidth='md'
sx={{
backgroundColor: '#ece9e9',
height: '15rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
}}
>
<Typography
variant='h2'
component='h1'
sx={{ color: '#1f1e1e', fontWeight: 500 }}
>
Home Page
</Typography>
</Box>
</Container>
);
};
export default HomePage;
Profile Page
src/pages/profile.page.tsx
import { Box, Container, Typography } from '@mui/material';
import { useStateContext } from '../context';
const ProfilePage = () => {
const stateContext = useStateContext();
const user = stateContext.state.authUser;
return (
<Container
maxWidth={false}
sx={{
backgroundColor: '#2363eb',
minHeight: '100vh',
}}
>
<Box
maxWidth='lg'
sx={{
backgroundColor: '#ece9e9',
maxHeight: '20rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: '2rem',
mx: 'auto',
}}
>
<Typography
variant='h2'
component='h1'
sx={{ color: '#1f1e1e', fontWeight: 500 }}
>
Profile Page
</Typography>
<Box sx={{ mt: 2 }}>
<Typography gutterBottom>
<strong>Id:</strong> {user?.id}
</Typography>
<Typography gutterBottom>
<strong>Full Name:</strong> {user?.name}
</Typography>
<Typography gutterBottom>
<strong>Email Address:</strong> {user?.email}
</Typography>
<Typography gutterBottom>
<strong>Role:</strong> {user?.role}
</Typography>
</Box>
</Box>
</Container>
);
};
export default ProfilePage;
Unauthorized Page
src/pages/unauthorize.page.tsx
import { Box, Container, Typography } from '@mui/material';
const UnauthorizePage = () => {
return (
<Container maxWidth='lg'>
<Box
sx={{
backgroundColor: '#ece9e9',
mt: '2rem',
height: '15rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography
variant='h2'
component='h1'
sx={{ color: '#1f1e1e', fontWeight: 500 }}
>
Unauthorized Page
</Typography>
</Box>
</Container>
);
};
export default UnauthorizePage;
Set up Routing with React-Router-Dom
React Router Dom v6 now supports object-based routing and since we are using Typescript we have to import the RouteObject
“type” from “react-router-dom“.
Next, we have to create an array of route objects and specify the routes to the different pages we defined above.
src/router/index.tsx
import { Suspense, lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
import FullScreenLoader from '../components/FullScreenLoader';
import Layout from '../components/layout';
import RequireUser from '../components/requireUser';
import HomePage from '../pages/home.page';
import LoginPage from '../pages/login.page';
import ProfilePage from '../pages/profile.page';
const Loadable =
(Component: React.ComponentType<any>) => (props: JSX.IntrinsicAttributes) =>
(
<Suspense fallback={<FullScreenLoader />}>
<Component {...props} />
</Suspense>
);
const RegisterPage = Loadable(lazy(() => import('../pages/register.page')));
const UnauthorizePage = Loadable(
lazy(() => import('../pages/unauthorize.page'))
);
const EmailVerificationPage = Loadable(
lazy(() => import('../pages/verifyemail.page'))
);
const authRoutes: RouteObject = {
path: '*',
children: [
{
path: 'login',
element: <LoginPage />,
},
{
path: 'register',
element: <RegisterPage />,
},
{
path: 'verifyemail',
element: <EmailVerificationPage />,
children: [
{
path: ':verificationCode',
element: <EmailVerificationPage />,
},
],
},
],
};
const normalRoutes: RouteObject = {
path: '*',
element: <Layout />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: 'profile',
element: <RequireUser allowedRoles={['user', 'admin']} />,
children: [
{
path: '',
element: <ProfilePage />,
},
],
},
{
path: 'unauthorized',
element: <UnauthorizePage />,
},
],
};
const routes: RouteObject[] = [authRoutes, normalRoutes];
export default routes;
Now in ./src/App.tsx
file, import the useRoutes
hook provided by React Router Dom to render the routes defined above.
src/App.tsx
import { CssBaseline } from '@mui/material';
import { useRoutes } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import routes from './router';
function App() {
const content = useRoutes(routes);
return (
<>
<CssBaseline />
<ToastContainer />
{content}
</>
);
}
export default App;
Add the QueryClient and Context API to the Root App
To make the data stored with the Context API available to all our React components, we need to wrap the root app in the StateContextProvider
component.
Also, the root app needs to be wrapped in the BrowserRouter component provided by React-Router-Dom to enable react-router-dom
to control the navigation between the different pages.
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { StateContextProvider } from './context';
import { BrowserRouter as Router } from 'react-router-dom';
import AuthMiddleware from './middleware/AuthMiddleware';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
},
},
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<Router>
<StateContextProvider>
<AuthMiddleware>
<App />
</AuthMiddleware>
</StateContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
</Router>
</QueryClientProvider>
</React.StrictMode>
);
Conclusion
With this React Query, Material UI, React Hook Form, and Axios interceptors example in Typescript, you’ve learned how to implement JSON Web Token Authentication and Authorization in React.js.
React Query and Axios Interceptors Source Code
Check out the complete source code for: