Context solves prop drilling — passing a value through five layers of components that don't use it themselves, just to reach a deeply nested consumer. Authentication state is the canonical use case: a nav bar, a checkout page, and a settings panel all need to know who's logged in, but that data changes infrequently enough that Context's lack of a selector layer isn't a real cost here.
Building a Fully Typed AuthContext
createContext needs a default value matching the context's type — but there's rarely a sensible default for something like login/logout functions outside a real Provider. The standard, type-safe pattern is to default to undefined, type the context as AuthContextValue | undefined, and force every consumer through a custom hook that throws if the Provider is missing.
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode
} from 'react'
interface AuthUser {
id: string
name: string
email: string
role: 'customer' | 'admin'
}
interface AuthContextValue {
user: AuthUser | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
// Default is `undefined`, not a fake stub object — this is what makes
// useAuth's runtime check (below) possible and meaningful.
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null)
const login = useCallback(async (email: string, password: string) => {
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('Invalid email or password')
}
const authenticatedUser: AuthUser = await response.json()
setUser(authenticatedUser)
}, [])
const logout = useCallback(() => {
setUser(null)
}, [])
// Memoize the context value object itself — without this, AuthProvider
// re-rendering for any reason creates a new object reference every time,
// which re-renders every single consumer regardless of whether user,
// login, or logout actually changed.
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: user !== null,
login,
logout
}),
[user, login, logout]
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
/**
* useAuth
* The only sanctioned way to read AuthContext. Throws immediately if called
* outside an AuthProvider, converting a silent `undefined` bug (accessing
* user on a missing context) into a loud, precise error at the exact call
* site, at development time, rather than a confusing crash deeper in the
* component that tries to read `.user` off undefined.
*/
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an <AuthProvider>')
}
return context
}Wiring the Provider and Consuming the Hook
import { AuthProvider } from './context/AuthContext'
import NavBar from './components/NavBar'
import ProductGrid from './components/ProductGrid'
function App() {
return (
<AuthProvider>
<NavBar />
<main className="container py-4">
<ProductGrid />
</main>
</AuthProvider>
)
}
export default Appimport { useState, type FormEvent } from 'react'
import { useAuth } from '../context/AuthContext'
function LoginForm() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await login(email, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="vstack gap-3" style={{ maxWidth: 360 }}>
{error && <div className="alert alert-danger py-2">{error}</div>}
<input
type="email"
className="form-control"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
className="form-control"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign In'}
</button>
</form>
)
}
export default LoginFormModule 3 Takeaways
- Default the context value to undefined, not a stub object — it lets the useAuth hook fail loudly and precisely if called outside its Provider, instead of silently rendering broken behavior.
- A custom hook (useAuth) wrapping useContext is the standard pattern — it centralizes the missing-Provider check in one place instead of repeating it at every call site.
- Memoize the Provider's context value object with useMemo — otherwise every Provider re-render creates a new object reference and re-renders every consumer, defeating the purpose of granular state.
- Context is well suited to low-frequency, broadly-read data like auth sessions; it has no selector layer, so it's a poor fit for high-frequency state like live cart totals.