React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

contextprovidersdependency-injectionhooks

useContext Hook

Read and subscribe to context from any component without prop drilling.

Recipe

Quick-reference recipe card — copy-paste ready.

// 1. Create context
const ThemeContext = createContext<Theme>("light");
 
// 2. Provide it
<ThemeContext.Provider value={theme}>
  <App />
</ThemeContext.Provider>
 
// 3. Consume it
const theme = useContext(ThemeContext);

When to reach for this: You need to share values (theme, auth, locale) across many components without passing props at every level.

Working Example

"use client";
 
import { createContext, useContext, useState, type ReactNode } from "react";
 
type Theme = "light" | "dark";
 
const ThemeContext = createContext<Theme>("light");
const ThemeToggleContext = createContext<() => void>(() => {});
 
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
 
  return (
    <ThemeContext.Provider value={theme}>
      <ThemeToggleContext.Provider value={toggle}>
        {children}
      </ThemeToggleContext.Provider>
    </ThemeContext.Provider>
  );
}
 
export function ThemeDisplay() {
  const theme = useContext(ThemeContext);
  const toggle = useContext(ThemeToggleContext);
 
  return (
    <div className={theme === "dark" ? "bg-gray-900 text-white p-4" : "bg-white text-black p-4"}>
      <p>Current theme: {theme}</p>
      <button onClick={toggle} className="mt-2 px-3 py-1 border rounded">
        Toggle Theme
      </button>
    </div>
  );
}

What this demonstrates:

  • Creating and providing context with createContext and Provider
  • Splitting value and updater into separate contexts to avoid unnecessary re-renders
  • Consuming context with useContext in a deeply nested component
  • Type-safe context with explicit generics

Deep Dive

How It Works

  • useContext finds the nearest Provider above the calling component in the tree
  • When the provider's value changes, every component consuming that context re-renders
  • If no provider is found, the default value passed to createContext is used
  • Context uses reference equality (Object.is) to detect changes — a new object reference triggers re-renders even if the contents are identical

Parameters & Return Values

ParameterTypeDescription
contextContext<T>The context object created by createContext
ReturnTypeDescription
valueTThe current context value from the nearest provider

Variations

Context with a custom hook (recommended pattern):

const AuthContext = createContext<AuthState | null>(null);
 
export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
  return ctx;
}

Multiple contexts composed:

export function AppProviders({ children }: { children: ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LocaleProvider>
          {children}
        </LocaleProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

Context with reducer for complex state:

const DispatchContext = createContext<Dispatch<Action>>(() => {});
const StateContext = createContext<AppState>(initialState);

TypeScript Notes

// Always type your context — avoid `any`
const UserContext = createContext<User | null>(null);
 
// Use a custom hook to narrow the type
export function useUser(): User {
  const user = useContext(UserContext);
  if (!user) throw new Error("useUser requires a UserProvider");
  return user; // narrowed to User, never null
}

Gotchas

  • Re-render avalanche — Passing a new object literal as value on every render causes all consumers to re-render. Fix: Memoize the value with useMemo or split into separate contexts.

  • Missing provider — Forgetting the Provider silently returns the default value, which may be undefined. Fix: Use a custom hook that throws if context is null.

  • Overusing context for high-frequency updates — Context is not optimized for values that change on every keystroke or animation frame. Fix: Use useSyncExternalStore, Zustand, or Jotai for high-frequency shared state.

  • Default value confusion — The default in createContext(defaultValue) is only used when there is no provider, not as an initial state for the provider. Fix: Always wrap consuming components in the appropriate provider.

Alternatives

AlternativeUse WhenDon't Use When
Prop drillingOnly 1–2 levels deep and few consumersMany levels or many consumers
Zustand / JotaiHigh-frequency updates or complex shared stateSimple, infrequent values like theme or locale
Component compositionChildren can be passed as props to avoid intermediate components needing the dataData is needed at many arbitrary levels
use(Context) (React 19)You want to read context conditionally or in loopsYou need to support React 18 or earlier

Why not always use Zustand? Context is built-in, requires no dependency, and is the right tool for low-frequency, tree-scoped values like theme, auth, or locale.

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

// Production example: ToastProvider with createContext and guard hook
// File: src/components/toast-provider.tsx
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
 
interface Toast {
  id: string;
  message: string;
  type: 'success' | 'error' | 'info';
}
 
interface ToastContextValue {
  toasts: Toast[];
  showToast: (message: string, type?: Toast['type']) => void;
  dismissToast: (id: string) => void;
}
 
const ToastContext = createContext<ToastContextValue | null>(null);
 
export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);
 
  const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
    const id = crypto.randomUUID();
    setToasts((prev) => [...prev, { id, message, type }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 5000);
  }, []);
 
  const dismissToast = useCallback((id: string) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);
 
  return (
    <ToastContext.Provider value={{ toasts, showToast, dismissToast }}>
      {children}
      <div className="fixed bottom-4 right-4 space-y-2 z-50">
        {toasts.map((toast) => (
          <div key={toast.id} className="rounded bg-gray-900 text-white px-4 py-2 shadow">
            {toast.message}
            <button onClick={() => dismissToast(toast.id)} className="ml-2">x</button>
          </div>
        ))}
      </div>
    </ToastContext.Provider>
  );
}
 
// Guard hook: throws if used outside the provider
export function useToast(): ToastContextValue {
  const ctx = useContext(ToastContext);
  if (!ctx) {
    throw new Error('useToast must be used within a ToastProvider');
  }
  return ctx;
}

What this demonstrates in production:

  • createContext<ToastContextValue | null>(null) uses null as the default so the guard hook can detect when it is used outside a provider. Using a real default value would silently fail instead of throwing a helpful error.
  • useCallback on showToast and dismissToast keeps these function references stable across renders. Without it, any component consuming useToast() would re-render on every toast state change because the context value object would contain new function references.
  • Functional state updates (setToasts((prev) => ...)) are used instead of reading toasts directly. This avoids adding toasts to the useCallback dependency array, which would break the stable reference.
  • The setTimeout for auto-dismiss captures the id in its closure and uses a functional updater to filter. This avoids stale closure bugs where the toasts array has changed by the time the timeout fires.
  • The guard hook useToast() narrows the type from ToastContextValue | null to ToastContextValue, so consumers never need to handle null. The throw ensures a clear error message during development if the provider is missing.

FAQs

Why should I split value and updater into separate contexts?
  • When a single context holds both the state value and its updater function, every consumer re-renders whenever the value changes.
  • Components that only call the updater (like a button) don't need the value, yet they re-render anyway.
  • Splitting into two contexts lets updater-only consumers avoid unnecessary re-renders.
What happens if I use useContext without a matching Provider?
  • React returns the default value passed to createContext(defaultValue).
  • If the default is undefined or null, your component may silently fail.
  • Use a guard hook that throws an error when the context is null to catch this during development.
How do I create a type-safe guard hook for context in TypeScript?
const MyContext = createContext<MyType | null>(null);
 
export function useMyContext(): MyType {
  const ctx = useContext(MyContext);
  if (!ctx) throw new Error("useMyContext must be used within a Provider");
  return ctx; // narrowed to MyType, never null
}
Gotcha: Why do all my context consumers re-render even when the value hasn't really changed?
  • Context uses Object.is to detect changes. Passing a new object literal { theme, toggle } as the provider value creates a new reference every render.
  • Fix by memoizing the value with useMemo:
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
Can I use context for high-frequency updates like typing or animations?
  • Context is not optimized for high-frequency updates because every consumer re-renders on every change.
  • For high-frequency shared state, use useSyncExternalStore, Zustand, or Jotai instead.
  • Context works well for low-frequency values like theme, auth, or locale.
What is the difference between the default value in createContext and the Provider's value prop?
  • The default value in createContext(default) is used only when there is no Provider above the consumer.
  • The Provider's value prop is the actual runtime value passed to consumers.
  • The default is not an "initial state" for the Provider.
How do I compose multiple Providers without deeply nesting JSX?
function AppProviders({ children }: { children: ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LocaleProvider>
          {children}
        </LocaleProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}
  • Alternatively, create a composeProviders utility that reduces an array of providers.
Should I use useContext or the React 19 use(Context) API?
  • use(Context) is new in React 19 and can be called inside conditions and loops, unlike useContext.
  • useContext is the standard approach if you need React 18 compatibility.
  • Both read from the nearest Provider; the difference is call-site flexibility.
Gotcha: Why does wrapping useCallback around context updater functions matter?
  • If the updater function is recreated every render, the context value object changes reference.
  • That triggers re-renders in all consumers, even if the state itself hasn't changed.
  • Wrapping updaters in useCallback keeps their references stable across renders.
How do I type createContext with TypeScript when the context may not have a Provider?
// Use null default + guard hook pattern
const UserContext = createContext<User | null>(null);
 
// The guard hook narrows the type
export function useUser(): User {
  const user = useContext(UserContext);
  if (!user) throw new Error("useUser requires UserProvider");
  return user;
}
  • useReducer — pair with context for complex state management
  • useMemo — memoize context values to prevent unnecessary re-renders
  • use — React 19's use() can read context conditionally
  • Custom Hooks — wrap useContext in a custom hook for type safety