React useContext Advanced Patterns

Advanced patterns for Context API, custom hooks, and state management.

TL;DR

  1. 01Use useSyncExternalStore to build fine-grained context subscriptions that skip unnecessary re-renders.
  2. 02Wrap components in a provider wrapper in tests to keep context consumers testable in isolation.
  3. 03Client-only context does not work in Server Components — pass data via props or use cookies/headers.

Tips

  1. 01Export a createTestWrapper function from your test utils that returns a wrapper component — this integrates cleanly with React Testing Library's wrapper option.
  2. 02Always expose async operations through the custom hook, not raw context — it keeps the async logic centralized.

Warnings

  1. 01This pattern requires careful implementation — prefer Zustand or Jotai in production for battle-tested selector behavior without the boilerplate.
  2. 02Never import a Server Component inside a Client Component — this breaks the React Server Component model. Server Components can receive Client Providers as children though.
  3. 03Don't over-fetch in Context providers — prefer React Query for server data and reserve Context for client-side state.

Custom Context Hooks

  • Wrap useContext in a custom hook to simplify access and centralise the context logic.

    const UserContext = createContext<{ user: User | null; setUser: (u: User | null) => void } | undefined>(undefined);
    
    export function UserProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
      return (
        <UserContext.Provider value={{ user, setUser }}>
          {children}
        </UserContext.Provider>
      );
    }
    
    export function useUser() {
      const context = useContext(UserContext);
      if (!context) throw new Error("useUser must be used inside UserProvider");
      return context;
    }
    
  • Throw a descriptive error in the hook when the context is missing to catch misconfigured trees early.

    export function useTheme() {
      const ctx = useContext(ThemeContext);
      if (ctx === undefined) {
        throw new Error("useTheme must be called inside ThemeProvider");
      }
      return ctx;
    }
    
  • Add derived values inside the hook so consumers never repeat the same logic.

    export function useAuth() {
      const { user } = useContext(AuthContext)!;
      return {
        user,
        isLoggedIn: !!user,             // derived
        isAdmin: user?.role === "admin", // derived
        displayName: user?.name ?? "Guest", // derived
      };
    }
    
  • Keep the raw context private — only export the provider and the custom hook.

    // auth-context.tsx
    const AuthContext = createContext<AuthState | undefined>(undefined); // NOT exported
    
    export function AuthProvider({ children }: { children: React.ReactNode }) { /* ... */ }
    export function useAuth() { return useContext(AuthContext)!; } // only hook exported
    
  • Export a single hook per context so consumers never call useContext directly.

    // consumers call the hook, not useContext — implementation stays hidden
    function Profile() {
      const { user, isAdmin } = useAuth(); // clean, type-safe API
      return <div>{isAdmin ? "Admin: " : ""}{user?.name}</div>;
    }
    

Context Selectors

  • Use useSyncExternalStore to build a selector pattern that only re-renders consumers when their selected slice changes.

    import { createContext, useContext, useRef, useSyncExternalStore } from "react";
    
    type Store<T> = { get: () => T; set: (val: Partial<T>) => void; subscribe: (cb: () => void) => () => void };
    
    function createStore<T>(initial: T): Store<T> {
      let state = initial;
      const listeners = new Set<() => void>();
      return {
        get: () => state,
        set: (partial) => { state = { ...state, ...partial }; listeners.forEach(l => l()); },
        subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }
      };
    }
    
    const StoreContext = createContext<Store<{ count: number; user: string }>>(null!);
    
    // Selector hook — only re-renders when the selected value changes
    function useStore<R>(selector: (state: { count: number; user: string }) => R): R {
      const store = useContext(StoreContext);
      return useSyncExternalStore(store.subscribe, () => selector(store.get()));
    }
    
    // Only re-renders when count changes, ignores user changes
    function Counter() {
      const count = useStore(s => s.count);
      return <p>{count}</p>;
    }
    
  • This pattern mimics Zustand's selector behavior inside React Context — useful when you want React Context ergonomics without an external library.

  • Compare to naive Context: without selectors, every consumer re-renders on any context change; with selectors, only consumers whose selected value changed re-render.

Testing Context Providers

  • Wrap components in their providers inside a test render helper so each test controls exactly what context the component receives.

    // test-utils.tsx
    import { render } from "@testing-library/react";
    import { UserProvider } from "../contexts/UserContext";
    import { ThemeProvider } from "../contexts/ThemeContext";
    
    export function renderWithProviders(ui: React.ReactElement, {
      user = null,
      theme = "light"
    } = {}) {
      return render(
        <UserProvider initialUser={user}>
          <ThemeProvider initialTheme={theme}>
            {ui}
          </ThemeProvider>
        </UserProvider>
      );
    }
    
  • Pass initial values to providers via props so tests can control state without mocking global modules.

    // In your provider, accept initial state as a prop
    export function UserProvider({ children, initialUser = null }) {
      const [user, setUser] = useState(initialUser);
      return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>;
    }
    
  • Test components that consume context by rendering them through the helper.

    test("shows welcome message for logged-in user", () => {
      const { getByText } = renderWithProviders(<Navbar />, {
        user: { name: "Alice", role: "admin" }
      });
      expect(getByText("Welcome, Alice")).toBeInTheDocument();
    });
    
  • Test the provider itself by rendering a consumer inside it and interacting with it.

    test("login updates user context", async () => {
      const { getByRole, findByText } = render(
        <UserProvider><LoginForm /><Navbar /></UserProvider>
      );
      await userEvent.click(getByRole("button", { name: "Login" }));
      expect(await findByText("Welcome, Alice")).toBeInTheDocument();
    });
    
  • Avoid mocking useContext directly — testing through real providers verifies that the full provider+consumer integration works correctly.

Context and Server Components

  • React Context requires a client component — Server Components cannot call useContext or render a <Provider>.

    // app/layout.tsx — Server Component (default in App Router)
    // Cannot use createContext or useContext here directly
    export default function RootLayout({ children }) {
      return <html><body><ClientProviders>{children}</ClientProviders></body></html>;
    }
    
  • Extract providers into a dedicated client component so the root layout stays a Server Component.

    // app/providers.tsx
    "use client";
    import { ThemeProvider } from "./contexts/ThemeContext";
    import { UserProvider } from "./contexts/UserContext";
    
    export function ClientProviders({ children }: { children: React.ReactNode }) {
      return (
        <ThemeProvider>
          <UserProvider>{children}</UserProvider>
        </ThemeProvider>
      );
    }
    
  • Pass initial data from Server Components to Context providers via props — fetch on the server, hydrate on the client.

    // app/layout.tsx (Server Component)
    import { getUser } from "@/lib/auth";
    import { ClientProviders } from "./providers";
    
    export default async function RootLayout({ children }) {
      const user = await getUser(); // server-side fetch
      return (
        <html><body>
          <ClientProviders initialUser={user}>{children}</ClientProviders>
        </body></html>
      );
    }
    
  • Use cookies() or headers() from next/headers in Server Components instead of Context for data that doesn't need client reactivity.

    import { cookies } from "next/headers";
    export default async function Page() {
      const theme = (await cookies()).get("theme")?.value ?? "light";
      return <div data-theme={theme}>...</div>;
    }
    
  • Server Components can be children of Client Context providers — they receive the context value when they re-render on the client.

Context with Async Operations

  • Define an async login function inside the provider and expose it through the context value.

    export function UserProvider({ children }: { children: React.ReactNode }) {
      const [user, setUser] = useState<User | null>(null);
      const [loading, setLoading] = useState(false);
    
      const login = async (email: string, password: string) => {
        setLoading(true);
        try {
          const result = await authenticate(email, password); // API call
          setUser(result);
        } finally {
          setLoading(false);
        }
      };
    
      return (
        <UserContext.Provider value={{ user, login, loading }}>
          {children}
        </UserContext.Provider>
      );
    }
    
  • Store loading and error alongside data in the context value so every consumer can react to async state.

    const [state, setState] = useState<{
      data: User | null;
      loading: boolean;
      error: string | null;
    }>({ data: null, loading: false, error: null });
    
    // Expose all three so consumers render loading/error UI without local state
    <DataContext.Provider value={state}>{children}</DataContext.Provider>
    
  • Dispatch async actions through useReducer to keep state transitions predictable and testable.

    type Action =
      | { type: "FETCH_START" }
      | { type: "FETCH_SUCCESS"; payload: User }
      | { type: "FETCH_ERROR"; payload: string };
    
    const [state, dispatch] = useReducer(asyncReducer, initialState);
    
    async function fetchUser(id: string) {
      dispatch({ type: "FETCH_START" });
      try {
        const user = await getUser(id);
        dispatch({ type: "FETCH_SUCCESS", payload: user });
      } catch (e) {
        dispatch({ type: "FETCH_ERROR", payload: (e as Error).message });
      }
    }
    
  • Cancel in-flight async operations on unmount to prevent state updates on unmounted providers.

    useEffect(() => {
      const controller = new AbortController();
    
      async function load() {
        try {
          const res = await fetch("/api/user", { signal: controller.signal });
          setUser(await res.json());
        } catch (e) {
          if ((e as Error).name !== "AbortError") setError("Failed to load");
        }
      }
    
      load();
      return () => controller.abort(); // cancel on unmount
    }, []);
    
  • Expose async methods via the custom hook so callers never interact with raw context directly.

    export function useUser() {
      const ctx = useContext(UserContext);
      if (!ctx) throw new Error("useUser must be used inside UserProvider");
    
      // Wrap async method to keep error handling in one place
      const safeLogin = async (email: string, password: string) => {
        await ctx.login(email, password);
      };
    
      return { ...ctx, login: safeLogin };
    }
    

FAQ