Recently I've been adding React Query to an existing React app. And slowly but surely I've been moving state management out of useState
+ useContext
.
Until I got to auth.
Authentication is a little different to the rest of the state that I need to keep in sync with the server:
- It's truly global
- I only need to fetch it once (and then log in and log out will handle the rest)
There's no point in ever re-requesting the user data because every API call checks if the user credentials have expired. If they are no longer authenticated, the API response will be an authentication error and will deal with it then.
So it kinda got me thinking. Should I stick with React context for user authentication or switch to React Query? Let's look at the code for both options and then I'll tell you why I ended up using both!
Authentication state with React Context
Context is perfect for sharing global data around your components - the docs* even mention this specific use case:
*OK, that's from the legacy docs, but the latest version of the docs has auth examples too!
Now I'm done convincing you that context is the perfect place for auth state, let's create our user context.
📄 User.context.js
import React from 'react';
const axios = require('axios');
// components
import Loading from '../components/Loading';
// user context
const UserContext = React.createContext(null);
// user provider
function UserProvider(props) {
const [user, setUser] = React.useState("loading"); // user (or false if not logged in)
// fetch user on mount
React.useEffect(() => {
// checks if user is logged in
axios.get('/api/user').then(function(user) {
setUser(user);
});
}, []); // run only once
// loading user
if (user === "loading") {
return <Loading />;
}
return (
<UserContext.Provider value={user} {...props} />
);
};
export { UserProvider };
So what's going on here?
When our context provider loads, we useEffect()
to make an API call to our service to get the user information. If the user is logged in, we will get the user object back in the response. If the user is not logged in, the response will be false
.
We will setUser()
so that our user
state is either the user object (for a logged-in user) or false
if they are not authenticated.
Ok, so far so good.
We will need to useContext()
anywhere in our app when we want the user state, so I'll add a custom hook called useUser()
for this.
function useUser() {
const context = React.useContext(UserContext)
if (context === undefined) {
throw new Error("useUser() must be used within a UserProvider");
}
return context
};
Instead of the useUser
hook, you can use const user = React.useContext(UserContext)
wherever you need the user instead, but I like using the hook because it will throw an error and let me know if I'm being a dummy and trying to get the user context outside of the context provider!
📄 User.context.js
// user context
const UserContext = React.createContext(null);
// user provider
function UserProvider(props) {
const [user, setUser] = React.useState("loading"); // user (or false if not logged in)
// fetch user on mount
React.useEffect(() => {
// checks if user is logged in
axios.get('/api/user').then(function(user) {
setUser(user);
});
}, []); // run only once
// loading user
if (user === "loading") {
return <Loading />;
}
return (
<UserContext.Provider value={user} {...props} />
);
};
// use user context hook
function useUser() {
const context = React.useContext(UserContext)
if (context === undefined) {
throw new Error("useUser() must be used within a UserProvider");
}
return context
};
export { UserProvider, useUser };
Now we can use the context, something like this:
📄 App.js
import React from 'react';
import { UserProvider, useUser } from './context/User.context.js'
import AuthenticatedRoutes from './router/Authenticated'
import LoginRoutes from './router/Login'
function App() {
const user = useUser();
return (
user ? <AuthenticatedRoutes /> : <LoginRoutes />
);
};
// wrap with user provider
export default function() {
return <UserProvider><App /></UserProvider>;
};
Here, we only return the authenticated routes in our app if the user is logged in. Otherwise, they get the login routes (login, register etc).
We can useUser()
anywhere in the app to get user information, for example, if we want to display their name or email address.
But we're not quite finished.
So far we are just checking if the user is logged in, but if they are not logged in, there's no way to log them in! We'll need to update our context if a user logs in, or logs out.
Let's add a couple of functions to our context provider, login()
and logout()
.
// user provider
function UserProvider(props) {
const [user, setUser] = React.useState("loading"); // user (or false if not logged in)
// fetch user on mount
React.useEffect(() => {
// checks if user is logged in
axios.get('/api/user').then(function(user) {
setUser(user);
});
}, []); // run only once
const login = useCallback((data) => {
axios.post('/api/user/login', data).then(function(user) {
setUser(user);
});
}, []);
const logout = useCallback(() => {
axios.post('/api/user/logout').then(function() {
setUser(false);
});
}, []);
// memo functions to optimise re-renders
const contextValue = useMemo(() => ({
user,
login,
logout,
}), [user, login, logout]);
// loading user
if (user === "loading") {
return <Loading />;
}
return (
<UserContext.Provider value={contextValue} {...props} />
);
};
useCallback()
is used on the functions, and useMemo()
for the context value because we are passing functions. You don't have to use these but it's to optimise and prevent unnecessary re-renders to any components using the context because functions never equal themselves - the docs explain it better than I can!
Now when we use the context, we'll need to get the user off the object instead:
const { user } = useUser();
But that also means we can get our login
and logout
functions too!
const { user, login, logout } = useUser();
Super!
Context was pretty easy to set up for auth, but let's try React Query and see how that feels.
Authentication state with React Query
React Query is great for fetching data from the server and managing that data in your app. And since we will be fetching the user data from the server, why not use React Query? 🤷♀️
Let's give it a go.
I'll start by creating a custom useGetUser()
hook for my query.
📄 useGetUser.hook.js
import { useQuery } from '@tanstack/react-query';
const axios = require('axios');
// api call
export function getUser() {
return axios.get('/api/user');
};
// user query
export function useGetUser() {
// run the query
return useQuery({
queryKey: ['user'],
queryFn: () => getUser(),
staleTime: Infinity,
cacheTime: Infinity,
});
};
I've set staleTime
and cacheTime
to infinity because I don't want to refetch the query. It's gonna be a one-time thing when the app loads where I check if the user is logged in or not. After that, every API call checks the authentication status anyway and will let me know if the user is no longer authenticated.
Now I can use the hook like this:
📄 App.js
import React from 'react';
import { useGetUser } from '../hooks/user';
import Loading from '../components/Loading';
import AuthenticatedRoutes from './router/Authenticated'
import LoginRoutes from './router/Login'
function App() {
const { data: user, ...userQuery } = useGetUser();
// loading user
if (userQuery.isLoading) {
return <Loading />;
}
return (
user ? <AuthenticatedRoutes /> : <LoginRoutes />
);
};
export default App;
Cool, cool. That all looks superb. And I can useGetUser()
anywhere I want it. It shouldn't refetch the query, because it will already be loaded and fresh (thanks staleTime
!).
And what about when we want to log a user in? Or log a user out?
We can use mutations!
Well, with our login
and logout
functions, we update the user data, so that sounds perfect.
📄 useLogin.hook.js
import { useQueryClient, useMutation } from '@tanstack/react-query';
// login mutation
export function useLogin() {
// get the query client
const queryClient = useQueryClient();
// create the mutation
return useMutation({
mutationFn: (data) => {
return axios.post('/api/user/login', data);
},
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
},
})
};
📄 useLogout.hook.js
import { useQueryClient, useMutation } from '@tanstack/react-query';
// login mutation
export function useLogout() {
// get the query client
const queryClient = useQueryClient();
// create the mutation
return useMutation({
mutationFn: () => {
return axios.post('/api/user/logout');
},
onSuccess: () => {
queryClient.setQueryData(['user'], false);
},
})
};
We can use the hooks whenever the user wants to log in or log out.
What I like about mutations is when I use them you get loading and error state out of the box. Here's an example of how I would use the useLogout()
mutation hook.
import React from 'react';
import Button from '../components/Button';
import { useLogout } from '../hooks/user';
function LogoutButton(props) {
const logout = useLogout();
return (
<>
{ logout.isError ? <div>{mutation.error.message}</div> : null }
<Button onClick={logout.mutate()} disabled={logout.isLoading}>Logout</Button>
</>
);
}
export default LogoutButton;
Authentication state with React Context + React Query
So far, we've handled the authentication state with context and we've also used React Query.
Either of these options seems to work, so which one is best?
Both?!
Hear me out!
You can use one, or the other. And they both seem to get the job done.
I like how React Query handles the isLoading
and all the async stuff. I wouldn't say you should use React Query just for this use case, but if you already use React Query, it's nice to use it here too.
But I don't need to call the useGetUser()
more than once, because I know that after that first call, I have the user data, and any further updates come from my mutations. I know that. But my components don't. And when I come back to this code in two years I probably won't remember either.
For that reason, I kinda prefer the context examples. I like having my auth state all in one clear place. If I need to see how the auth state is handled, I know to look in User.context.js
because that's where I'm getting the state from everywhere else in my app.
My future self will thank me for it!
And there are a few other issues you TypeScript devs might run into with the React Query examples that you can find a detailed explanation for on TkDodo's blog.
Inspired by that blog post, I thought, why not try both?! I realised I could probably get the best of both worlds by combining React Query and React Context. The handy isLoading
and mutations of React Query, and the data sharing of React Context.
So let's refactor our code, and see how we can handle authentication, and our global user state, using React Query and React Context together.
We'll keep all the hooks we created for React Query earlier.
📄 useGetUser.hook.js
import { useQuery } from '@tanstack/react-query';
const axios = require('axios');
// api call
export function getUser() {
return axios.get('/api/user');
};
// user query
export function useGetUser() {
// run the query
return useQuery({
queryKey: ['user'],
queryFn: () => getUser(),
staleTime: Infinity,
cacheTime: Infinity,
});
};
The useGetUser()
hook will be used in our context to fetch the user. We can remove useState
and useEffect
. And we can also remove the login
and logout
functions along with useCallback
and useMemo
. Context looks much simpler now because we can go back to just passing the user as the value.
📄 User.context.js
import React from 'react';
const axios = require('axios');
// components
import Loading from '../components/Loading';
// hooks
import { useGetUser } from '../hooks/user';
// user context
const UserContext = React.createContext(null);
// user provider
function UserProvider(props) {
const { data: user, ...userQuery } = useGetUser();
// loading user
if (userQuery.isLoading) {
return <Loading />;
}
return (
<UserContext.Provider value={user} {...props} />
);
};
// use user context hook
function useUser() {
const context = React.useContext(UserContext)
if (context === undefined) {
throw new Error("useUser() must be used within a UserProvider");
}
return context
};
export { UserProvider, useUser };
And we can use the useLogin()
and useLogout()
mutation hooks we created earlier to update the user and manage authentication in our app.
I think that the global authentication state can be handled pretty well in React using context. But if you already use React Query in your app, you can sprinkle that in and get some extra data-fetching tools (isPaused
, isLoading
, errors etc) when using mutations for logging in and logging out.