useAuth Hook: Building Custom Authentication in React
Authentication is one of the most critical features in modern web applications, yet it's often built in an ad-hoc manner without proper abstraction. Whether you're integrating with a third-party authentication service or building your own, the logic tends to scatter across components—login forms here, permission checks there, token refresh logic everywhere.
In this guide, we'll build a production-ready useAuth custom hook that encapsulates all authentication logic in one place. You'll learn how to manage authentication state with proper error handling, persist tokens securely, implement token refresh mechanisms, and provide a clean API for checking user permissions. This approach is used by companies like Alibaba and ByteDance to maintain consistency across their applications.
Table of Contents
- Authentication State Management
- Basic useAuth Hook
- Token Persistence & Refresh
- Permission Checking
- Error Handling & Recovery
- Protected Routes Integration
- Real-World Implementation
- FAQ
Authentication State Management
Authentication state typically includes several pieces of information: the current user, authentication tokens (both access and refresh), loading states during async operations, and error states when something goes wrong. Rather than managing these separately, a custom hook consolidates them into a cohesive state object.
The key insight is that authentication state has dependencies. A user is only "authenticated" if we have valid tokens. Those tokens need to be refreshed before expiration. Error states should reset when the user takes corrective action. A well-designed hook handles these interdependencies automatically, preventing inconsistent states.
Modern applications often use JWT (JSON Web Tokens) for stateless authentication. These tokens contain encoded information about the user and their permissions, making them ideal for client-side applications. However, managing token lifecycle—including expiration and refresh—requires careful implementation.
Basic useAuth Hook
Let's start with the foundation. A basic useAuth hook manages login, logout, and provides access to the current user and authentication state.
TypeScript Version
import { useState, useCallback, useRef, useEffect } from 'react';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthTokens {
accessToken: string;
refreshToken?: string;
}
interface UseAuthReturn {
user: User | null;
isLoading: boolean;
error: Error | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
}
export function useAuth(): UseAuthReturn {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const tokensRef = useRef<AuthTokens | null>(null);
const clearError = useCallback(() => {
setError(null);
}, []);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed. Please check your credentials.');
}
const data = await response.json();
tokensRef.current = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
// Decode JWT to get user info (simplified)
const userPayload = JSON.parse(
atob(data.accessToken.split('.')[1])
);
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
} catch (err) {
const error = err instanceof Error ? err : new Error('Login failed');
setError(error);
setUser(null);
tokensRef.current = null;
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(async () => {
setIsLoading(true);
try {
// Notify server of logout
if (tokensRef.current?.accessToken) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokensRef.current.accessToken}`,
},
});
}
} catch (err) {
console.error('Logout error:', err);
} finally {
setUser(null);
tokensRef.current = null;
setIsLoading(false);
setError(null);
}
}, []);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
login,
logout,
clearError,
};
}
JavaScript Version
import { useState, useCallback, useRef } from 'react';
export function useAuth() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const tokensRef = useRef(null);
const clearError = useCallback(() => {
setError(null);
}, []);
const login = useCallback(async (email, password) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed. Please check your credentials.');
}
const data = await response.json();
tokensRef.current = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
const userPayload = JSON.parse(
atob(data.accessToken.split('.')[1])
);
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
} catch (err) {
const error = err instanceof Error ? err : new Error('Login failed');
setError(error);
setUser(null);
tokensRef.current = null;
} finally {
setIsLoading(false);
}
}, []);
const logout = useCallback(async () => {
setIsLoading(true);
try {
if (tokensRef.current?.accessToken) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokensRef.current.accessToken}`,
},
});
}
} catch (err) {
console.error('Logout error:', err);
} finally {
setUser(null);
tokensRef.current = null;
setIsLoading(false);
setError(null);
}
}, []);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
login,
logout,
clearError,
};
}
Notice we use useRef to store tokens instead of state. This prevents unnecessary re-renders when tokens update, since token changes don't affect the UI directly.
Token Persistence & Refresh
In production applications, you need to persist authentication tokens so users stay logged in across page refreshes. You also need to refresh expired access tokens using the refresh token before making API requests.
Advanced TypeScript Version
import { useState, useCallback, useRef, useEffect } from 'react';
interface User {
id: string;
email: string;
name: string;
role: string;
}
interface AuthTokens {
accessToken: string;
refreshToken?: string;
}
interface TokenPayload {
exp: number; // Unix timestamp in seconds
sub: string;
email: string;
name: string;
role: string;
}
interface UseAuthOptions {
tokenStorageKey?: string;
onTokenRefresh?: (tokens: AuthTokens) => void;
onSessionExpired?: () => void;
}
interface UseAuthReturn {
user: User | null;
isLoading: boolean;
error: Error | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
refreshToken: () => Promise<boolean>;
}
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const {
tokenStorageKey = 'auth_tokens',
onTokenRefresh,
onSessionExpired,
} = options;
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const tokensRef = useRef<AuthTokens | null>(null);
const refreshTimeoutRef = useRef<NodeJS.Timeout>();
// Decode JWT and check if it's valid
const decodeToken = (token: string): TokenPayload | null => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload;
} catch {
return null;
}
};
const isTokenExpired = (token: string): boolean => {
const payload = decodeToken(token);
if (!payload) return true;
return Date.now() >= payload.exp * 1000;
};
// Schedule token refresh before expiration
const scheduleTokenRefresh = useCallback(
(token: string) => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
const payload = decodeToken(token);
if (!payload) return;
// Refresh 5 minutes before expiration
const expiresIn = payload.exp * 1000 - Date.now();
const refreshIn = Math.max(expiresIn - 5 * 60 * 1000, 1000);
refreshTimeoutRef.current = setTimeout(() => {
refreshToken().catch(console.error);
}, refreshIn);
},
[]
);
const saveTokens = useCallback((tokens: AuthTokens) => {
tokensRef.current = tokens;
try {
localStorage.setItem(tokenStorageKey, JSON.stringify(tokens));
} catch {
console.warn('Failed to persist tokens');
}
scheduleTokenRefresh(tokens.accessToken);
}, [tokenStorageKey, scheduleTokenRefresh]);
const loadTokens = useCallback(() => {
try {
const stored = localStorage.getItem(tokenStorageKey);
if (stored) {
const tokens = JSON.parse(stored);
tokensRef.current = tokens;
// Check if tokens are still valid
if (!isTokenExpired(tokens.accessToken)) {
const userPayload = decodeToken(tokens.accessToken);
if (userPayload) {
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
scheduleTokenRefresh(tokens.accessToken);
}
} else {
localStorage.removeItem(tokenStorageKey);
}
}
} catch (err) {
console.error('Failed to load tokens:', err);
localStorage.removeItem(tokenStorageKey);
}
}, [tokenStorageKey, scheduleTokenRefresh]);
// Load tokens on mount
useEffect(() => {
loadTokens();
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [loadTokens]);
const clearError = useCallback(() => {
setError(null);
}, []);
const refreshToken = useCallback(async (): Promise<boolean> => {
if (!tokensRef.current?.refreshToken) {
return false;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: tokensRef.current.refreshToken,
}),
});
if (!response.ok) {
onSessionExpired?.();
return false;
}
const data = await response.json();
const newTokens = {
accessToken: data.accessToken,
refreshToken: data.refreshToken || tokensRef.current.refreshToken,
};
saveTokens(newTokens);
onTokenRefresh?.(newTokens);
return true;
} catch (err) {
console.error('Token refresh failed:', err);
onSessionExpired?.();
return false;
}
}, [onTokenRefresh, onSessionExpired, saveTokens]);
const login = useCallback(async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
const data = await response.json();
const tokens = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
saveTokens(tokens);
const userPayload = decodeToken(tokens.accessToken);
if (userPayload) {
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Login failed');
setError(error);
setUser(null);
tokensRef.current = null;
} finally {
setIsLoading(false);
}
}, [saveTokens]);
const logout = useCallback(async () => {
setIsLoading(true);
try {
if (tokensRef.current?.accessToken) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokensRef.current.accessToken}`,
},
});
}
} catch (err) {
console.error('Logout error:', err);
} finally {
setUser(null);
tokensRef.current = null;
try {
localStorage.removeItem(tokenStorageKey);
} catch {
// Handle case where localStorage is unavailable
}
setIsLoading(false);
setError(null);
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
}
}, [tokenStorageKey]);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
login,
logout,
clearError,
refreshToken,
};
}
Advanced JavaScript Version
import { useState, useCallback, useRef, useEffect } from 'react';
export function useAuth(options = {}) {
const {
tokenStorageKey = 'auth_tokens',
onTokenRefresh,
onSessionExpired,
} = options;
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const tokensRef = useRef(null);
const refreshTimeoutRef = useRef();
const decodeToken = (token) => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload;
} catch {
return null;
}
};
const isTokenExpired = (token) => {
const payload = decodeToken(token);
if (!payload) return true;
return Date.now() >= payload.exp * 1000;
};
const scheduleTokenRefresh = useCallback((token) => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
const payload = decodeToken(token);
if (!payload) return;
const expiresIn = payload.exp * 1000 - Date.now();
const refreshIn = Math.max(expiresIn - 5 * 60 * 1000, 1000);
refreshTimeoutRef.current = setTimeout(() => {
refreshToken().catch(console.error);
}, refreshIn);
}, []);
const saveTokens = useCallback((tokens) => {
tokensRef.current = tokens;
try {
localStorage.setItem(tokenStorageKey, JSON.stringify(tokens));
} catch {
console.warn('Failed to persist tokens');
}
scheduleTokenRefresh(tokens.accessToken);
}, [tokenStorageKey, scheduleTokenRefresh]);
const loadTokens = useCallback(() => {
try {
const stored = localStorage.getItem(tokenStorageKey);
if (stored) {
const tokens = JSON.parse(stored);
tokensRef.current = tokens;
if (!isTokenExpired(tokens.accessToken)) {
const userPayload = decodeToken(tokens.accessToken);
if (userPayload) {
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
scheduleTokenRefresh(tokens.accessToken);
}
} else {
localStorage.removeItem(tokenStorageKey);
}
}
} catch (err) {
console.error('Failed to load tokens:', err);
localStorage.removeItem(tokenStorageKey);
}
}, [tokenStorageKey, scheduleTokenRefresh]);
useEffect(() => {
loadTokens();
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [loadTokens]);
const clearError = useCallback(() => {
setError(null);
}, []);
const refreshToken = useCallback(async () => {
if (!tokensRef.current?.refreshToken) {
return false;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refreshToken: tokensRef.current.refreshToken,
}),
});
if (!response.ok) {
onSessionExpired?.();
return false;
}
const data = await response.json();
const newTokens = {
accessToken: data.accessToken,
refreshToken: data.refreshToken || tokensRef.current.refreshToken,
};
saveTokens(newTokens);
onTokenRefresh?.(newTokens);
return true;
} catch (err) {
console.error('Token refresh failed:', err);
onSessionExpired?.();
return false;
}
}, [onTokenRefresh, onSessionExpired, saveTokens]);
const login = useCallback(async (email, password) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
const data = await response.json();
const tokens = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
saveTokens(tokens);
const userPayload = decodeToken(tokens.accessToken);
if (userPayload) {
setUser({
id: userPayload.sub,
email: userPayload.email,
name: userPayload.name,
role: userPayload.role,
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error('Login failed');
setError(error);
setUser(null);
tokensRef.current = null;
} finally {
setIsLoading(false);
}
}, [saveTokens]);
const logout = useCallback(async () => {
setIsLoading(true);
try {
if (tokensRef.current?.accessToken) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokensRef.current.accessToken}`,
},
});
}
} catch (err) {
console.error('Logout error:', err);
} finally {
setUser(null);
tokensRef.current = null;
try {
localStorage.removeItem(tokenStorageKey);
} catch {
// Handle case where localStorage is unavailable
}
setIsLoading(false);
setError(null);
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
}
}, [tokenStorageKey]);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
login,
logout,
clearError,
refreshToken,
};
}
This version persists tokens to localStorage and automatically refreshes them five minutes before expiration. This ensures the user stays authenticated across page refreshes and API calls don't fail due to expired tokens.
Permission Checking
Real-world applications often need to verify user permissions before allowing access to certain features. Let's extend the hook with permission checking capabilities.
TypeScript Version
import { useCallback } from 'react';
import { useAuth } from './useAuth';
interface PermissionConfig {
requiredPermissions?: string[];
requiredRoles?: string[];
requireAll?: boolean; // true = AND logic, false = OR logic
}
export function usePermission() {
const { user } = useAuth();
const hasPermission = useCallback(
(config: PermissionConfig): boolean => {
if (!user) return false;
const { requiredPermissions = [], requiredRoles = [], requireAll = false } = config;
// Check roles
if (requiredRoles.length > 0) {
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) return false;
}
// Check permissions (typically stored in JWT payload)
if (requiredPermissions.length > 0) {
const userPermissions = (user as any).permissions || [];
if (requireAll) {
return requiredPermissions.every((p) => userPermissions.includes(p));
} else {
return requiredPermissions.some((p) => userPermissions.includes(p));
}
}
return true;
},
[user]
);
const hasRole = useCallback(
(roles: string | string[]): boolean => {
if (!user) return false;
const roleArray = Array.isArray(roles) ? roles : [roles];
return roleArray.includes(user.role);
},
[user]
);
return { hasPermission, hasRole, user };
}
JavaScript Version
import { useCallback } from 'react';
import { useAuth } from './useAuth';
export function usePermission() {
const { user } = useAuth();
const hasPermission = useCallback(
(config) => {
if (!user) return false;
const { requiredPermissions = [], requiredRoles = [], requireAll = false } = config;
if (requiredRoles.length > 0) {
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) return false;
}
if (requiredPermissions.length > 0) {
const userPermissions = user.permissions || [];
if (requireAll) {
return requiredPermissions.every((p) => userPermissions.includes(p));
} else {
return requiredPermissions.some((p) => userPermissions.includes(p));
}
}
return true;
},
[user]
);
const hasRole = useCallback(
(roles) => {
if (!user) return false;
const roleArray = Array.isArray(roles) ? roles : [roles];
return roleArray.includes(user.role);
},
[user]
);
return { hasPermission, hasRole, user };
}
Error Handling & Recovery
Authentication errors should be handled gracefully with user-friendly messages and recovery options. Let's look at how to implement comprehensive error handling.
TypeScript Version with Error Recovery
interface AuthError extends Error {
code: string;
statusCode?: number;
retryable: boolean;
}
export class AuthErrorHandler {
static createError(
message: string,
code: string,
statusCode?: number,
retryable = false
): AuthError {
const error = new Error(message) as AuthError;
error.code = code;
error.statusCode = statusCode;
error.retryable = retryable;
return error;
}
static getErrorMessage(error: any): string {
if (error?.code === 'INVALID_CREDENTIALS') {
return 'Email or password is incorrect. Please try again.';
}
if (error?.code === 'SESSION_EXPIRED') {
return 'Your session has expired. Please log in again.';
}
if (error?.code === 'NETWORK_ERROR') {
return 'Network error. Please check your connection.';
}
if (error?.statusCode === 429) {
return 'Too many login attempts. Please try again later.';
}
return error?.message || 'An authentication error occurred.';
}
static isRetryable(error: any): boolean {
return error?.retryable || error?.statusCode === 429;
}
}
JavaScript Version with Error Recovery
export class AuthErrorHandler {
static createError(
message,
code,
statusCode = undefined,
retryable = false
) {
const error = new Error(message);
error.code = code;
error.statusCode = statusCode;
error.retryable = retryable;
return error;
}
static getErrorMessage(error) {
if (error?.code === 'INVALID_CREDENTIALS') {
return 'Email or password is incorrect. Please try again.';
}
if (error?.code === 'SESSION_EXPIRED') {
return 'Your session has expired. Please log in again.';
}
if (error?.code === 'NETWORK_ERROR') {
return 'Network error. Please check your connection.';
}
if (error?.statusCode === 429) {
return 'Too many login attempts. Please try again later.';
}
return error?.message || 'An authentication error occurred.';
}
static isRetryable(error) {
return error?.retryable || error?.statusCode === 429;
}
}
Protected Routes Integration
Here's how to integrate authentication with route protection:
TypeScript Version
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from './useAuth';
interface ProtectedRouteProps {
children: ReactNode;
requiredRoles?: string[];
fallback?: ReactNode;
}
export function ProtectedRoute({
children,
requiredRoles,
fallback,
}: ProtectedRouteProps) {
const { user, isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return fallback || <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRoles && !requiredRoles.includes(user?.role || '')) {
return <Navigate to="/forbidden" replace />;
}
return children;
}
JavaScript Version
import { Navigate } from 'react-router-dom';
import { useAuth } from './useAuth';
export function ProtectedRoute({
children,
requiredRoles,
fallback,
}) {
const { user, isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return fallback || <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRoles && !requiredRoles.includes(user?.role || '')) {
return <Navigate to="/forbidden" replace />;
}
return children;
}
Real-World Implementation
Here's a complete authentication flow in a practical scenario, similar to what you'd implement in a ByteDance or Alibaba-scale application.
TypeScript Version
import { useState } from 'react';
import { useAuth } from './useAuth';
import { usePermission } from './usePermission';
interface LoginFormProps {
onLoginSuccess?: () => void;
}
export function LoginForm({ onLoginSuccess }: LoginFormProps) {
const { login, isLoading, error, clearError } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
try {
await login(email, password);
onLoginSuccess?.();
} catch (err) {
// Error is already in the auth state
}
};
return (
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
/>
</div>
{error && <div className="error-message" role="alert">{error.message}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
// Using the permission hook
interface DashboardProps {}
export function Dashboard() {
const { user, logout } = useAuth();
const { hasRole } = usePermission();
return (
<div className="dashboard">
<header>
<h1>Welcome, {user?.name}</h1>
<button onClick={logout}>Log Out</button>
</header>
{hasRole('admin') && (
<section className="admin-section">
<h2>Admin Panel</h2>
<p>Only admins can see this section.</p>
</section>
)}
<section className="user-content">
<h2>Your Data</h2>
<p>Role: {user?.role}</p>
</section>
</div>
);
}
JavaScript Version
import { useState } from 'react';
import { useAuth } from './useAuth';
import { usePermission } from './usePermission';
export function LoginForm({ onLoginSuccess }) {
const { login, isLoading, error, clearError } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
clearError();
try {
await login(email, password);
onLoginSuccess?.();
} catch (err) {
// Error is already in the auth state
}
};
return (
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
required
/>
</div>
{error && <div className="error-message" role="alert">{error.message}</div>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
export function Dashboard() {
const { user, logout } = useAuth();
const { hasRole } = usePermission();
return (
<div className="dashboard">
<header>
<h1>Welcome, {user?.name}</h1>
<button onClick={logout}>Log Out</button>
</header>
{hasRole('admin') && (
<section className="admin-section">
<h2>Admin Panel</h2>
<p>Only admins can see this section.</p>
</section>
)}
<section className="user-content">
<h2>Your Data</h2>
<p>Role: {user?.role}</p>
</section>
</div>
);
}
FAQ
Q: Should I store JWT tokens in localStorage or sessionStorage?
A: Neither is perfectly safe, but localStorage is more practical since it persists across browser tabs. SessionStorage is more secure but logs users out when the tab closes. Consider: use localStorage for "remember me" functionality, sessionStorage for sensitive financial apps, or httpOnly cookies (server-side) for maximum security. The hook uses localStorage by default but you can customize this.
Q: How do I handle the case where the refresh token expires?
A: When the refresh token itself expires, the onSessionExpired callback is triggered. At this point, redirect the user to the login page and clear their session. The hook handles this through the onSessionExpired option passed during initialization.
Q: Can I use this hook with third-party auth providers like Auth0 or Firebase?
A: Absolutely! You'd adapt the login function to call the third-party provider's API instead of your own. The rest of the hook logic remains the same. The key is abstracting the authentication mechanism from the state management.
Q: What if the access token needs to be included in API requests automatically?
A: Create an HTTP interceptor that reads the access token from tokensRef.current and adds it to request headers. Many libraries like Axios support interceptors. Alternatively, use the React useEffect hook to update an HTTP client configuration when the token changes.
Q: How do I handle permission changes that happen server-side while the user is logged in?
A: Store permission metadata in the access token's payload (typical for JWT). When the token is refreshed, new permissions are automatically available. For real-time permission updates, you'd need a separate mechanism like WebSocket events that trigger a token refresh.
Q: Is it safe to decode JWTs on the client side?
A: Yes, because JWTs are signed, not encrypted. The payload is readable but tamper-proof (thanks to the signature). Never trust the decoded data as the source of truth—always verify permissions server-side when handling sensitive operations. Client-side decoding is only for UI purposes.
Related Articles:
- Custom Hooks Patterns in React
- useReducer: Complex State Management
- useEffect: Deep Dive Into Effect Lifecycle
- Context API Patterns and Best Practices
- Error Handling Best Practices
Questions? Share your authentication challenges and implementation patterns in the comments below! Whether you're integrating with Auth0, Firebase, or building custom auth, let's discuss approaches and best practices for your use case.
Google AdSense Placeholder
CONTENT SLOT