Every application should handle an authentication flow; in this article, you'll learn how to build an authentication flow in your React Application with React Query.
Sign Up
The first step to build an authentication flow is the sign-up action. As you have already learned in this series, you should build a mutation to do this action. A possible solution could be this
async function signUp(email: string, password: string): Promise<User> {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
})
if (!response.ok)
throw new ResponseError('Failed on sign up request', response);
return await response.json();
}
type IUseSignUp = UseMutateFunction<User, unknown, {
email: string;
password: string;
}, unknown>
export function useSignUp(): IUseSignUp {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const { mutate: signUpMutation } = useMutation<User, unknown, { email: string, password: string }, unknown>(
({
email,
password
}) => signUp(email, password), {
onSuccess: (data) => {
// TODO: save the user in the state
navigate('/');
},
onError: (error) => {
enqueueSnackbar('Ops.. Error on sign up. Try again!', {
variant: 'error'
});
}
});
return signUpMutation
}
By creating a mutation like that, you build the signUp in a very simple and clear way.
Now using the useSignUp
hook, you can get the mutation and call the signUp request to create a new user in your system. As you can notice, the code is pretty simple; the signUp
method calls the API to post the new user's data and return the user data saved in the database. Then using the useMutation
hook, you can build the mutation to handle the signUp action. If everything goes ok, the onSuccess
hook calls the navigation to the home page; otherwise, the onError
hook shows a toast with an error.
In the code, there is a TODO that indicates something missing; we'll get back to this line in the future of this post.
Sign In
The second step to build if you are building an authentication flow is SignIn. In this case, SignIn is pretty similar to SignUp; the only things that change are the endpoint and the scope of the hook.
So the code can be this
async function signIn(email: string, password: string): Promise<User> {
const response = await fetch('/api/auth/signin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
})
if (!response.ok)
throw new ResponseError('Failed on sign in request', response);
return await response.json();
}
type IUseSignIn = UseMutateFunction<User, unknown, {
email: string;
password: string;
}, unknown>
export function useSignIn(): IUseSignIn {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const { mutate: signInMutation } = useMutation<User, unknown, { email: string, password: string }, unknown>(
({
email,
password
}) => signIn(email, password), {
onSuccess: (data) => {
// TODO: save the user in the state
navigate('/');
},
onError: (error) => {
enqueueSnackbar('Ops.. Error on sign in. Try again!', {
variant: 'error'
});
}
});
return signInMutation
}
I don't want to spend much time describing this hook because it is very similar to the SignUp but only with the references for the SignIn. Also in this case, there is a TODO that we'll remove in the future of the post.
The user
The core part of an authentication flow is where you save the user in the state. To do that, in this case, the best way is to create a new hook called useUser
which is the owner of the user data.
The useUser
hook must have the user's data, and it has to save the user's data in the local storage and retrieve them when the user refreshes the page or gets back in the future.
Let's start with the code that handles the local storage. Typically, this code is created with small functions with a specific goal like the next.
import { User } from './useUser';
const USER_LOCAL_STORAGE_KEY = 'TODO_LIST-USER';
export function saveUser(user: User): void {
localStorage.setItem(USER_LOCAL_STORAGE_KEY, JSON.stringify(user));
}
export function getUser(): User | undefined {
const user = localStorage.getItem(USER_LOCAL_STORAGE_KEY);
return user ? JSON.parse(user) : undefined;
}
export function removeUser(): void {
localStorage.removeItem(USER_LOCAL_STORAGE_KEY);
}
In this way, you can create a small module that handles all the local storage functions for the user.
Now it's time to see how you can build the useUser
hook.
Let's start with the code
async function getUser(user: User | null | undefined): Promise<User | null> {
if (!user) return null;
const response = await fetch(`/api/users/${user.user.id}`, {
headers: {
Authorization: `Bearer ${user.accessToken}`
}
})
if (!response.ok)
throw new ResponseError('Failed on get user request', response);
return await response.json();
}
export interface User {
accessToken: string;
user: {
email: string;
id: number;
}
}
interface IUseUser {
user: User | null;
}
export function useUser(): IUseUser {
const { data: user } = useQuery<User | null>(
[QUERY_KEY.user],
async (): Promise<User | null> => getUser(user),
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
initialData: userLocalStorage.getUser,
onError: () => {
userLocalStorage.removeUser();
}
});
useEffect(() => {
if (!user) userLocalStorage.removeUser();
else userLocalStorage.saveUser(user);
}, [user]);
return {
user: user ?? null,
}
}
The getUser
function is simple; it provides the HTTP request to get the user info; if the user is null, return null otherwise, it calls the HTTP endpoint.
The useQuery
hook is similar to the others seen before, but there are two new configurations to understand.
refetchOnMount : this option is important to prevent the hook reloads the data each time it is used
initialData : this option is used to load the data from the local storage; the initialData accepts a function that returns the initial value; if the initial value is defined, react query uses this value to refresh the data.
Now you have all the blocks of the authentication flow, but it's time to link useSignUp
and useSignIn
with the useUser
hook.
Using the QueryClient you can set the data of a specific query by the setQueryData function.
So the previous TODOs comments change in this way
export function useSignUp(): IUseSignUp {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const { mutate: signUpMutation } = useMutation<User, unknown, { email: string, password: string }, unknown>(
({
email,
password
}) => signUp(email, password), {
onSuccess: (data) => {
queryClient.setQueryData([QUERY_KEY.user], data);
navigate('/');
},
onError: (error) => {
enqueueSnackbar('Ops.. Error on sign up. Try again!', {
variant: 'error'
});
}
});
return signUpMutation
}
export function useSignIn(): IUseSignIn {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const { mutate: signInMutation } = useMutation<User, unknown, { email: string, password: string }, unknown>(
({
email,
password
}) => signIn(email, password), {
onSuccess: (data) => {
queryClient.setQueryData([QUERY_KEY.user], data);
navigate('/');
},
onError: (error) => {
enqueueSnackbar('Ops.. Error on sign in. Try again!', {
variant: 'error'
});
}
});
return signInMutation
}
With two simple lines of code, you can set the user in the useUser
state because the key used to set the query data is the same as the useUser
.
Then, with an useEffect
in the useUser
hook, you can remove or set the user data in the local storage when the user changes
export function useUser(): IUseUser {
const { data: user } = useQuery<User | null>(
[QUERY_KEY.user],
async (): Promise<User | null> => getUser(user),
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
initialData: userLocalStorage.getUser,
onError: () => {
userLocalStorage.removeUser();
}
});
useEffect(() => {
if (!user) userLocalStorage.removeUser();
else userLocalStorage.saveUser(user);
}, [user]);
return {
user: user ?? null,
}
}
To complete the authentication flow, the only missing thing is the logout.
You can build it with a custom hook called useSignOut
; its implementation is straightforward and could be done in this way
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { QUERY_KEY } from '../constants/queryKeys';
type IUseSignOut = () => void
export function useSignOut(): IUseSignOut {
const queryClient = useQueryClient();
const navigate = useNavigate();
const onSignOut = useCallback(() => {
queryClient.setQueryData([QUERY_KEY.user], null);
navigate('/auth/sign-in');
}, [navigate, queryClient])
return onSignOut
}
As you can notice, the hook returns a simple function that clears the value in the user state and navigates to the sign-in
page.
Ok, perfect. Now you have all the notions of building an authentication flow with React Query, but If you want to find out more, watch my youtube video about authentication with React Query
Ok, that's all from authentication.
I hope you enjoyed this content!
See you soon folks
Bye Bye ๐
p.s. you can find the code of the video here
Photo by Rahul Mishra on Unsplash
Top comments (14)
Never save your access token in
localStorage
. This is potential security vulnerability!. Use HttpOnly cookie instead.localStorage
can be accessed by any script running on your website. Any 3rd party code, like analytics for example, can steal your user's access token fromlocalStorage
.HttpOnly
cookie can be set only via server's response and it can't be accessed via javascript, but will be send back to the server with http requests likefetch
. So your server can read back an access token, but no external scripts can read it.If you can't use cookies don't store your access token at all. Just keep it in your state. To avoid entering password every time user is refreshing/opening in new tab you can implement and store id token client-side, but fetch new access token from API every time. But then... It's better to just use any OAuth library.
Yes, I know. But for this demo I think it is a good enough the token in the localstorage. It is not the best solution but for giving an idea of how you can create an authentication flow with react query is okay.
I know and I understand your point of view, and also I agree with you but probably for this example it is too much.
But I appreciate your comment that helps people to understanding the problem ๐
HttpOnly cookie is also not safe. If you use localStorage to store a token, you should make the token short life time and also use black list.
So, do you believe that the most appropriate option would be to use cookies and JWT tokens with expiration dates and everything? @bartoszkrawczyk2
I always have this question about the most appropriate way to store tokens, an opinion from someone with more experience would be nice
@myguelangello I am no expert, but two approaches I've seen in production apps:
Don't do it client side. Let your API handle 3rd party tokens or OAuth flow and use HttpOnly cookie for browser-server communication. This is approach used in passport.js, Auth.js, Lucia Auth and all popular auth libraries. Lucia for example stores JWT tokens in DB and issues a HttpOnly cookie for your front-end app.
If you need to do JWT based auth client side (again, not recommended, but sometimes necessary), store only id_token in localStorage and keep auth_token in your app state, private variable etc. When token is lost (eg. page refresh) redirect again to your OAuth provider. If your session is still valid you will be redirected back to your app, if not user will see log in screen. I am not sure how safe is this approach.
Most important - don't roll out your own auth. Use auth solution provided by your backend framework. Many frameworks (Laravel for example) have good authentication flow built in. If your framework doesn't provide any auth solution use something well known and documented (eg. passport.js for express app).
How can I handle httpOnly cookie if android/ios application consumes the api?
There are many ways, one I've used successfully is JSON Web Token (or JWT) where we defined a expiration date of few days (the requests used a refresh token util then and after the token became invalid, so user should re-login). But there are alternatives like OAuth, biometric auth, Single sign-on...
To handle cookie for android/ios, it's easier for you to use SecureStore from expo to store cookie.
@sohanurrahman0 Sorry, I don't have any experience with mobile authentication, but AFAIK OAuth flow (with separate id token and access token) is more commonly used on mobile.
It's good to have more articles showing how great react-query is, and how something which pretty much all used apps, like authentication, is being showcased here.
My only critique would be to reduce the use of generics (and explicit typing in general), by simply type-casting the API calls, and letting the library's TS inference takeover for what the Fn is and it's variables.
Essentially reducing the verbose nature of defining these custom hooks.
Itโs not so explicit but here there is a way to do that ๐ the post doesnโt show the integration with react but itโs a good starting point ๐
dev.to/this-is-learning/validate-y...
Very useful example!
Thanks Tiago! Iโm glad you enjoy it ๐
Very useful example keep going.