DEV Community

Cover image for React Query - Authentication Flow
Luca Del Puppo for This is Learning

Posted on • Originally published at blog.delpuppo.net on

React Query - Authentication Flow

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
bartoszkrawczyk2 profile image
Bartosz Krawczyk

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 from localStorage. 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 like fetch. 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.

Collapse
 
puppo profile image
Luca Del Puppo

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 🙌

Collapse
 
kazumasadakane profile image
KazumaSadakane

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.

Collapse
 
myguelangello profile image
Myguel Angello • Edited

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

Collapse
 
bartoszkrawczyk2 profile image
Bartosz Krawczyk • Edited

@myguelangello I am no expert, but two approaches I've seen in production apps:

  1. 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.

  2. 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).

Collapse
 
sohanurrahman0 profile image
Sohanur Rahman

How can I handle httpOnly cookie if android/ios application consumes the api?

Collapse
 
tiagosatur profile image
Tiago Satur

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...

Collapse
 
thomasluuu profile image
ThomasLuuu

To handle cookie for android/ios, it's easier for you to use SecureStore from expo to store cookie.

Collapse
 
bartoszkrawczyk2 profile image
Bartosz Krawczyk

@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.

Collapse
 
seancassiere profile image
Sean Cassiere

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.

Collapse
 
puppo profile image
Luca Del Puppo

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...

Collapse
 
tiagosatur profile image
Tiago Satur

Very useful example!

Collapse
 
puppo profile image
Luca Del Puppo

Thanks Tiago! I’m glad you enjoy it 🙌

Collapse
 
jwilliams profile image
Jessica williams

Very useful example keep going.