Skip to content

Auth Context and useAuth Hook

Authentication state needs to be accessible throughout the app — the navbar shows the username, protected routes redirect anonymous users, and forms include the user’s ID. React Context is the right tool.

src/context/AuthContext.tsx
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import { TOKEN_KEY, USER_KEY } from '../constants'
interface AuthUser {
userId: number
username: string
}
interface AuthContextValue {
user: AuthUser | null
token: string | null
login: (token: string, user: AuthUser) => void
logout: () => void
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => {
const stored = localStorage.getItem(USER_KEY)
return stored ? JSON.parse(stored) : null
})
const [token, setToken] = useState<string | null>(() =>
localStorage.getItem(TOKEN_KEY)
)
function login(newToken: string, newUser: AuthUser) {
localStorage.setItem(TOKEN_KEY, newToken)
localStorage.setItem(USER_KEY, JSON.stringify(newUser))
setToken(newToken)
setUser(newUser)
}
function logout() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{
user,
token,
login,
logout,
isAuthenticated: !!token && !!user,
}}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

The useState initializers read from localStorage once on mount:

const [token, setToken] = useState<string | null>(() =>
localStorage.getItem(TOKEN_KEY)
)

This is a lazy initializer — the function runs only once, not on every render. Without it, the initial state would be null and the user would see a brief “logged out” flash before the component reads localStorage.

// In Navbar
function Navbar() {
const { user, isAuthenticated, logout } = useAuth()
return (
<nav>
<Link to="/">Bulletin</Link>
{isAuthenticated ? (
<>
<span>Hello, {user!.username}</span>
<button onClick={logout}>Logout</button>
</>
) : (
<>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
</>
)}
</nav>
)
}

The auth interceptor reads from localStorage directly — it already works. But if you want to trigger logout via React state when a 401 occurs, inject the logout function into the interceptor:

// src/api/client.ts — update after AuthProvider is set up
export function setupAuthInterceptor(logout: () => void) {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
logout()
}
return Promise.reject(error)
}
)
}

Call this inside AuthProvider or a component at the top of the tree.

  1. Create src/context/AuthContext.tsx with the code above.
  2. Wrap App with <AuthProvider>.
  3. Use useAuth in Navbar to show the username when logged in.
  4. Confirm that after calling logout(), the user state clears and the navbar updates.
  • AuthContext stores user, token, login, and logout for app-wide access.
  • Lazy useState initializers read from localStorage once — no logged-out flash on reload.
  • useAuth throws if used outside AuthProvider — prevents silent bugs.
  • isAuthenticated is a derived boolean: !!token && !!user.