Skip to Content
SnippetsSafe Context

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 | nullnull 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 in useMemo.
  • Why null and not undefined? React context defaults to undefined when no default is provided. Using null as the sentinel avoids ambiguity — an undefined field inside a real context value won’t trigger a false positive.
  • displayName sets 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 const on the return gives a tuple type. Without it, TypeScript infers a union array and you lose the distinct types when destructuring.
Last updated on