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.

src/context/AuthContext.tsxtypescript
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

src/App.tsxtypescript
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 App
src/components/LoginForm.tsxtypescript
import { 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 LoginForm

Module 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.