React useContext Advanced Patterns
Advanced patterns for Context API, custom hooks, and state management.
TL;DR
- 01Use useSyncExternalStore to build fine-grained context subscriptions that skip unnecessary re-renders.
- 02Wrap components in a provider wrapper in tests to keep context consumers testable in isolation.
- 03Client-only context does not work in Server Components — pass data via props or use cookies/headers.
Tips
- 01Export a createTestWrapper function from your test utils that returns a wrapper component — this integrates cleanly with React Testing Library's wrapper option.
- 02Always expose async operations through the custom hook, not raw context — it keeps the async logic centralized.
Warnings
- 01This pattern requires careful implementation — prefer Zustand or Jotai in production for battle-tested selector behavior without the boilerplate.
- 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.
- 03Don't over-fetch in Context providers — prefer React Query for server data and reserve Context for client-side state.
Custom Context Hooks
Wrap
useContextin 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 exportedExport a single hook per context so consumers never call
useContextdirectly.// 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
useSyncExternalStoreto 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
useContextdirectly — 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
useContextor 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()orheaders()fromnext/headersin 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
loadinganderroralongside 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
useReducerto 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
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.