Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 147
Intermediate

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.
Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 148
Intermediate

React useContext Advanced Patterns

(continued)

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>;
    }
    
Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 149
Intermediate

React useContext Advanced Patterns

(continued)

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.

Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 150
Intermediate

React useContext Advanced Patterns

(continued)

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.

Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 151
Intermediate

React useContext Advanced Patterns

(continued)

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.

Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 152
Intermediate

React useContext Advanced Patterns

(continued)

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 };
    }
    
Notes
Useful Cheatsheetsusefulcheatsheets.com
React useContext Advanced Patterns
Chapter 21 · Page 153
Intermediate

React useContext Advanced Patterns

(FAQ)

FAQ

Split your context by domain so components only subscribe to the slice of state they need. For example, keep user auth in one context and theme preferences in another — updating auth won't re-render theme consumers.

Reach for useReducer when your context state has multiple sub-values that change together or when state transitions depend on previous state. It also makes it easier to share both state and dispatch through the same context.

Pass an initializer function as the second argument to useReducer, or compute the initial value inside a useState initializer function — this runs only once on mount rather than on every render.

Create a custom hook like useAuth() that calls useContext internally and throws a descriptive error if used outside the provider. This catches misconfigured component trees early and keeps consumer code clean.

Store both data and loading/error status in the context state, then trigger fetches via useEffect inside the provider component. Expose the status alongside the data so consumers can react to loading and error states without managing them locally.