Use React context to store authorization state
The authorization state is something normally is stored in a global state. But there's a better approach: using react Context. By wrapping all the application with a single context, the effect is the same as having a global state. And as bonus, it's a react standard way to share state and it could be expanded to scoped state with no changes.
Overview #
At the end, we're able to do this:
src/App.tsx
:
import React from "react";
import { AuthProvider } from "./context/AuthContext";
const App: React.FC = () => (
<AuthProvider onLogin={(user, password) => token}>
<Routes />
</AuthProvider>
);
All components inside AuthContext
will be able to access the authorization state using a hook:
src/Routes.tsx
:
const Routes: React.FC = () => {
const [auth] = useAuth();
return auth.user ? <AuthenticatedRoutes /> : <UnauthenticatedRoutes />;
};
export default Routes;
The only caveat is that your componets must be inside the <AuthContext>
context.
React Context crash course (with types) #
Basically, you can create a Context with any value like this:
src/contex/NameContext.tsx
:
type NameContextData = {
name: "";
};
export const NameContext = createContext<NameContextData>({ name: "" });
Then you can scope a component to that context:
<NameContext.Provider value=>
<OtherComponent />
</NameContext.Provider>
And then, inside OtherComponent
access the context data:
import NameContext from "./context/NameContext";
const OtherComponent = () => {
const data: NameContextData = useContext(NameContext);
return <div>My name is: {data.name}</div>;
};
The approach #
Notice above that, when using the Context.Provider
you must specifiy the value. What we're going to do is to prefill that value automatically with an state, so instead of:
<AuthContext.Provider value={...}>
<App />
</AuthContext.Provider>
We will use a custom component that returns the prefilled provider:
<AuthProvider>
<App />
</AuthProvider>
We will prefill the provider with an state (defined by a reducer), so using the dispatch method, we will be able to change the state for all scoped components. Let's go!
The Auth state #
The auth state will be something like this:
type AuthState = {
user?: string;
token?: string;
error?: string;
loading?: boolean;
};
Depending on your app you will need to add some fields (like, for example, the user's role) or remove some (the token
?)
Then, we're going to define the state logic with a reducer:
import React, { createContext, useReducer, useCallback, useMemo } from "react";
const initialState: AuthState = {};
type StartAction = { type: "login" };
type SuccessAction = { type: "success"; user: string; token: string };
type ErrorAction = { type: "failed"; message: string };
type LogoutAction = { type: "logout" };
type Action = StartAction | SuccessAction | ErrorAction | LogoutAction;
function reducer(state: AuthState, action: Action) {
switch (action.type) {
case "login":
return { loading: true };
case "success":
return { loading: false, username: action.user, token: action.token };
case "failed":
return { loading: false, error: action.message };
case "logout":
return { loading: false };
default:
return state;
}
}
Create the Context #
Our context needs to store the auth state, but also the dispatcher (or some mechanism) to change the state.
Instead of exposing the dispatch function directly (an option) we'll provide a simple interface to login and logout:
src/context/AuthContext.ts
:
type AuthActions = {
login: (user: string, password: string) => void;
logout: () => void;
};
So our context will a value of [state, actions]
:
src/context/AuthContext.ts
:
type ContextProps = [AuthState, AuthActions];
export const AuthContext = createContext<ContextProps>([
{},
{ login: () => undefined, logout: () => undefined },
]);
Create the provider #
Now, we are going to create our custom AuthProvider
that returns an actual AuthContext.Provider
. We're going to accept some functions to know when the state changed and to store or retrieve that state from a storage (cookie or localStorage, for example):
src/context/AuthContext.ts
:
type ProviderProps = {
onLogin: (user: string, password: string) => Promise<string>;
onLogout: () => void;
loadState?: () => AuthState;
saveState?: (state: Partial<AuthState>) => void;
};
export const AuthProvider: React.FC<ProviderProps> = ({
children,
onLogin,
onLogout,
loadState,
saveState,
}) => {
const [state, dispatch] = useReducer(reducer, loadState());
const login = useCallback(
async (user: string, password: string) => {
try {
dispatch({ type: "login" });
const token = await onLogin(user, password);
saveState({ user, token });
dispatch({ type: "success", user, token });
} catch (error) {
saveState({});
dispatch({ type: "failed", message: "Invalid credentials" });
}
},
[dispatch, onLogin]
);
const logout = useCallback(() => {
saveState({});
dispatch({ type: "logout" });
onLogout();
}, [dispatch, onLogout]);
const actions = { login, logout };
return (
<AuthContext.Provider value={[state, actions]}>
{children}
</AuthContext.Provider>
);
};
Add the useAuth hook #
The useAuth
hook is quite trivial:
src/hooks/useAuth.ts
:
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuth = () => useContext(AuthContext);
export default useAuth;
Then you can use it like this:
const [{ user, token }, { logout }] = useAuth();
Maybe you can write another utility hook:
src/hooks/useUser.ts
:
import useAuth from "./useAuth";
export const useUser = (): string => {
const [auth] = useAuth();
return auth.user;
};
export default useUser;
Bonus: LocalStorage #
The local storage is not the safest place to store auth state, but it will do the job:
const LOCAL_STORAGE_KEY = "auth-state";
function saveStateLocalStorage(state: Partial<AuthState>) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
}
function loadStateLocalLocalStorage(): AuthState {
try {
const data = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || "{}");
if (data.user && data.token) {
return { user: data.user, token: data.token };
} else {
return {};
}
} catch (e) {
return {};
}
}