Safe Context
A factory that creates a type-safe React Context with a Provider and a hook that throws if used outside the Provider.
The snippet
import { createContext, useContext, type ReactNode } from 'react'
function createSafeContext<T>(scope: string, defaultValue?: T) {
const Context = createContext<T | null>(defaultValue ?? null)
Context.displayName = `${scope}Context`
function useSafeContext(): T {
const ctx = useContext(Context)
if (ctx === null) {
throw new Error(
`use${scope} must be used within <${scope}Provider>. ` +
`Wrap your component tree in <${scope}Provider value={...}>.`
)
}
return ctx
}
function Provider({ value, children }: { value: T; children: ReactNode }) {
return <Context.Provider value={value}>{children}</Context.Provider>
}
Provider.displayName = `${scope}Provider`
return [Provider, useSafeContext] as const
}Types
The generic T is the shape of the context value. Internally the context holds T | null — null is the sentinel for “no provider above me.” When useContext returns null, the hook throws with a message that names the missing provider. The as const return type gives a tuple (readonly [Provider, Hook]) so destructuring preserves the individual types.
function createSafeContext<T>(
scope: string, // names the Provider, hook, and error message
defaultValue?: T, // optional — if set, hook works outside Provider
): readonly [
(props: { value: T; children: ReactNode }) => JSX.Element,
() => T,
]Passing a defaultValue sets the context default to that value instead of null. This means the hook will return the default when used outside a Provider rather than throwing — useful for contexts with sensible fallbacks.
Usage
import { useState, useMemo } from 'react'
// 1. Create the context
type Theme = { mode: 'light' | 'dark'; toggle: () => void }
const [ThemeProvider, useTheme] = createSafeContext<Theme>('Theme')
// 2. Provide it — memoize the value to prevent unnecessary re-renders
function App() {
const [mode, setMode] = useState<'light' | 'dark'>('light')
const theme = useMemo(
() => ({
mode,
toggle: () => setMode((m) => (m === 'light' ? 'dark' : 'light')),
}),
[mode]
)
return (
<ThemeProvider value={theme}>
<Page />
</ThemeProvider>
)
}
// 3. Consume it
function Page() {
const { mode, toggle } = useTheme()
return <button onClick={toggle}>Current: {mode}</button>
}
// 4. Using outside the Provider throws
function Orphan() {
const theme = useTheme()
// Error: "useTheme must be used within <ThemeProvider>."
}Notes
- Memoize the value. The Provider is a thin wrapper around
Context.Provider. If you pass a new object reference every render, all consumers re-render. Wrap the value inuseMemo. - Why
nulland notundefined? React context defaults toundefinedwhen no default is provided. Usingnullas the sentinel avoids ambiguity — anundefinedfield inside a real context value won’t trigger a false positive. displayNamesets the label shown in React DevTools for both the Context and Provider, making it easier to debug which context is which.- Default values make the hook work outside a Provider — it returns the default instead of throwing. Skip the second argument for contexts that should always require a Provider (auth, routing, etc.).
as conston the return gives a tuple type. Without it, TypeScript infers a union array and you lose the distinct types when destructuring.
Last updated on