skip to content

Instead of exporting an AuthContext for callers to pass to useContext, many React codebases export only a custom useAuth() hook that throws when it finds no value. What does that pattern buy you, and how do you implement it?

level: middleimportance: must knowfreq 70%

answer

  1. fail where the mistake was made
  2. the context object stays private
  3. null would spread through every call site
  4. one narrowing point for the type
  5. error message names the missing Provider

basics

~20 s

The hook turns "you forgot the Provider" into an immediate, named error at the misuse site instead of a null that spreads through the app. Create the context with null, read it in the hook, throw if it is null, and export only the hook and the Provider.

solid answer

~50 s

You create the context with `null`, then wrap the read: `const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>'); return ctx;`. The module exports the Provider and the hook — not the context object. Three things come from that. First, misuse fails loudly at the component that misused it, with a message naming the Provider, instead of `null.user` crashing three layers away. Second, TypeScript narrows once inside the hook, so consumers get a non-nullable type instead of every call site checking for null. Third, the context object stays private, so nobody can render their own Provider with an ad-hoc value or read the context in a way you did not intend — which leaves you free to change the internal shape later. The cost is a little boilerplate per context, usually worth it.

go deeper

for a junior

Be able to write the hook: read the context, throw a clear error when it is missing, return the value — and say why that beats letting null travel onward.

for a middle

Explain what the pattern encapsulates: the context object stays private, the nullable type is narrowed in one place, and the value shape becomes free to change without touching consumers.

for a senior

Show judgment about when the throw is wrong — genuinely optional context — and how you keep tests and Storybook working once callers can no longer construct the context themselves.

for a principal

Treat the hook and Provider as the module's entire public contract across teams, and weigh what you can evolve behind it versus what an exported context object would freeze forever.

## The pattern in full ```jsx import { createContext, useContext, useMemo, useState } from 'react'; const AuthContext = createContext(null); // not exported export function AuthProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo(() => ({ user, signIn: setUser }), [user]); return <AuthContext value={value}>{children}</AuthContext>; } export function useAuth() { const ctx = useContext(AuthContext); if (ctx === null) { throw new Error('useAuth must be used inside <AuthProvider>'); } return ctx; } ``` The module's public surface is two names: `AuthProvider` and `useAuth`. The context object itself is a private implementation detail. ## What the throw actually buys you **Failure lands where the mistake is.** Without the check, a component rendered outside the provider receives `null`, renders something odd, and crashes later — often in a child, on a line that has nothing to do with the missing provider. The stack trace points at the victim, not the culprit. With the check, the error message names both the hook and the provider a developer must add, and React's component stack points at the exact component that called it. Debugging time drops from twenty minutes to zero. **The type narrows once.** In TypeScript, `createContext<Session | null>(null)` makes the context type nullable forever. Every consumer would otherwise write `if (!auth) return null`. The hook narrows in one place and returns `Session`, so the nullability never escapes the module. **Encapsulation.** If you export the context object, callers can render `<AuthContext value={whateverIWant}>` and fabricate a session, or read it directly and depend on the internal value shape. Keeping it private means the value shape is yours to change: you can split it, rename fields, or swap `useState` for `useReducer` inside the provider without touching a single consumer. ## Why a real default value cannot do this job The tempting alternative is `createContext({ user: null, signIn: () => {} })` — a shape-complete no-op default so nothing crashes. That is exactly the failure mode you do not want for required context: the app renders, the sign-in button silently does nothing, and nobody learns that the provider is missing. A default is right when running without a provider is a *legitimate* mode (a theme, a locale). It is wrong when the context carries something the component genuinely cannot function without. The same argument rules out returning `null` from the hook and "letting the caller decide". That just relocates the check into every caller, which is where you were before. ## Variants worth knowing - **Sentinel instead of null.** If `null` is a legitimate context value, create the context with a private sentinel object and compare identity against it. Rare, but it is the correct fix when null is meaningful. - **A non-throwing escape hatch.** Occasionally you genuinely want optional participation — a component that renders inside a provider when there is one and standalone otherwise. Then export a second hook, `useAuthOrNull()`, that returns the raw read, and implement the throwing one on top of it. Two named entry points beat one hook with a boolean flag argument. - **Development-only messaging.** Some libraries throw a terse error in production and a verbose one in development. Do not throw *only* in development: the divergence means a bug that development caught becomes a silent null in production. ## Where the throw actually fires It fires during render, so it propagates like any render-time error: the nearest error boundary above it catches it, or the root unmounts. That is intentional — a missing provider is a programming error, not a user-facing condition to recover from. Do not wrap the hook in a try/catch to "handle" it. ## Testing implications Because consumers can no longer construct the context themselves, tests must render inside the real provider. Publish a small test wrapper from the same module so tests get one blessed way in. This is a feature: your tests exercise the real provider wiring instead of a hand-made fake value that has drifted from the real shape. ## What interviewers listen for They want the mechanism (create with null, check inside the hook, throw with a message that names the provider), the *reason* (fail at the misuse site, narrow the type once, keep the value shape private), and the judgment to say when a plain default is the better call instead.

  • When would you not throw, and return the raw value instead?
    When rendering without the provider is a legitimate mode. A theme, locale, or analytics context can sensibly fall back. For those, give the context a real default and skip the check — or export both hooks, a throwing `useX()` and a tolerant `useXOrNull()`, so the optional case is explicit at the call site rather than implied.
  • What is the drawback of not exporting the context object at all?
    Callers lose the ability to render their own Provider — which is mostly the point, but it bites when someone legitimately needs to inject a value: a test harness, a Storybook decorator, or an embedded widget. Handle it by exporting a purpose-built test wrapper or a `value` prop on your Provider, not by leaking the context.
  • Where does that thrown error surface at runtime?
    It throws during render, so it behaves like any render-time error: the nearest error boundary above the component catches it, and without one React unmounts the root. That is correct for a missing provider — it is a wiring bug that should be impossible to miss, not a recoverable state.

saying these in an interview costs you the question

  • Says the throw prevents unnecessary re-renders
  • Gives the context a full no-op default so nothing crashes
  • Returns null from the hook and checks in every caller
  • Throws only in development builds
  • Wraps the hook in try/catch to handle the error

context