Skip to content

Using React Context the Right Way

Aman Kumar Singh6 min read
Part 8 of 50From the React & Next.js Complete Guide series
Using React Context the Right Way — article by Aman Kumar Singh

In Custom Hooks Worth Writing, I made the case for extracting logic into hooks only once you've written the same pattern three or four times, not on the first sighting of duplication. Context has the opposite problem in most codebases. Teams reach for it on day one, before they've felt any actual pain from prop drilling, and end up with a handful of giant providers that re-render half the app on every keystroke.

This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals and works up to production Next.js patterns. Context sits at an awkward spot in that progression: it's simple enough to misuse immediately and subtle enough that the consequences don't show up until the component tree is large.

The core problem Context solves is narrow: passing a value to a subtree without threading it through every intermediate component as props. It's a plumbing mechanism. Treating it as a general state management solution is where most of the pain in this article comes from.

What Context is actually for

Prop drilling gets a bad reputation it doesn't always deserve. Passing userId through three components that don't use it themselves is annoying, but it's also explicit: anyone reading the code can trace exactly where a value comes from and where it's going. That traceability is worth something.

Context earns its place when a value is genuinely cross-cutting: the current authenticated user, a theme, a locale, a feature-flag set, something dozens of unrelated components at different depths need to read. Think of it as a mechanism for avoiding repetitive plumbing. It doesn't replace thinking about where state should live.

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

Notice the default value is undefined, not a fake object. That forces the custom hook to throw a clear error if someone consumes the context outside its provider, instead of silently rendering with a meaningless default. I've debugged enough "theme is stuck on light mode for no reason" tickets to know this five-line guard saves real time later.

Always pair Context with a custom hook

Exporting ThemeContext directly and having consumers call useContext(ThemeContext) everywhere works, but it pushes the undefined check and the error message into every call site, or more realistically, into none of them. Wrapping the context in a hook like useTheme centralizes that check once, and it also gives you a seam for changing the underlying implementation later without touching every consumer.

This pairing is worth treating as a rule rather than a suggestion: never export a raw context for consumption. Export the provider component and the hook. The context object itself is an implementation detail.

// auth-context.tsx
type User = { id: string; email: string; role: "admin" | "member" };

type AuthContextValue = {
  user: User | null;
  isLoading: boolean;
  logout: () => Promise<void>;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchCurrentUser()
      .then(setUser)
      .finally(() => setIsLoading(false));
  }, []);

  const logout = useCallback(async () => {
    await api.post("/auth/logout");
    setUser(null);
  }, []);

  const value = useMemo(() => ({ user, isLoading, logout }), [user, isLoading, logout]);

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

Every component that needs the current user calls useAuth() and gets a typed, guaranteed-present value. No prop threading, no optional chaining on a value that should never actually be missing.

The re-render problem nobody warns you about

Here's the part that trips up teams who adopt Context early and never revisit it: every component that consumes a context re-renders whenever that context's value changes, regardless of which part of the value it actually reads. If your AuthContext bundles user, isLoading, and a dozen other fields, and any one of them changes, every consumer re-renders, even the ones that only care about user.role.

This gets worse when providers hold onto unrelated pieces of state together out of convenience. A common anti-pattern: one AppContext holding the current user, the active theme, a notification count, and a sidebar-open boolean. Toggling the sidebar now re-renders every component reading theme or user, even though those values didn't change.

The useMemo wrapping the context value in the examples above isn't decoration. Without it, the provider creates a brand-new object on every render of the provider component itself, which means every consumer re-renders even when none of the underlying values actually changed. It's a one-line fix that's easy to forget and easy to miss in review. The code still works. It's just slower than it needs to be.

Splitting contexts along change frequency

The fix for the bundling problem is to split contexts by how often their data changes and by who actually needs them, not by how conceptually related they feel. user and theme might both feel like "app-level state," but they change for completely different reasons and on completely different schedules, so they belong in separate providers.

function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NotificationProvider>{children}</NotificationProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

This looks like more boilerplate than a single combined provider, and it is, slightly. What you get in exchange is isolation: a notification count ticking up doesn't force every themed component to re-render, and a theme toggle doesn't touch anything reading auth state. For a small app this distinction barely matters. For a dashboard with hundreds of components subscribed to app-level state, it's the difference between a snappy UI and one that stutters every time something unrelated updates.

A second technique worth knowing: split a single concern into a "state" context and a "dispatch" or "actions" context when the state changes often but the update functions never do. Components that only call logout() and never read user can subscribe to the actions context alone and never re-render when the user object changes.

When Context isn't the right tool

Context is not a state management library. Pushing it to do that job is the most common production pitfall I see. Symptoms that you've outgrown Context: state that needs to be updated from many unrelated places with complex logic, state that needs middleware-style behavior (logging, undo, persistence), or a genuine need to read a slice of state without re-rendering on unrelated slices within the same context.

At that point, reaching for a dedicated store, Zustand, Redux Toolkit, Jotai, whichever fits your team's existing conventions, is matching the tool to a problem Context was never designed to solve. These libraries let components subscribe to a slice of state without re-rendering on changes elsewhere in the store, something Context fundamentally cannot do without manual splitting.

The reasoning I'd apply, consistent with deferring complexity until it's earned elsewhere in this series: start with Context for genuinely cross-cutting, infrequently changing values like auth and theme. If you find yourself splitting contexts more than two or three times to chase a performance problem, or writing reducer logic inside a provider that's grown past a few actions, that's the signal to move to a proper store rather than a reason to avoid Context from the start.

Key takeaways

  • Context solves prop drilling for genuinely cross-cutting values like auth, theme, and locale. It is not a general state management replacement.
  • Always default the context value to `undefined` and pair it with a custom hook that throws a clear error if used outside its provider.
  • Never export a raw context for direct consumption; export the provider and the hook, and keep the context object as an implementation detail.
  • Wrap the context value in `useMemo` so the provider doesn't hand consumers a new object reference on every render.
  • Every consumer of a context re-renders on any change to that context's value. Split contexts by how frequently their data changes, not by how conceptually related the fields feel.
  • Reach for a dedicated store once you need selective subscriptions to slices of state or complex update logic that Context and a reducer can't cleanly express.

Frequently asked questions

What's the difference between Context and a state management library like Redux or Zustand?

Context is a plumbing mechanism for passing a value down a component tree without prop drilling. State management libraries add selective subscriptions, so a component can read one slice of state without re-rendering when an unrelated slice changes, plus middleware-style features like persistence or logging. Context can't do selective subscription within a single provider without manual splitting.

Why does my component re-render even though it only reads one field from my context?

Because Context doesn't support partial subscriptions. Any change to the context value re-renders every consumer of that context, regardless of which field they actually use. The fix is splitting the context into smaller, more narrowly scoped providers or moving to a store that supports selectors.

Do I need useMemo around every context value?

Yes, for any context value that's an object or array, which is nearly all of them. Without `useMemo`, the provider creates a new reference on every render, which defeats memoization in consumers and causes unnecessary re-renders even when the underlying data hasn't changed.

Should I put my entire app's state in one big Context?

No. Bundling unrelated state, like auth, theme, and UI toggles, into a single context means any change to any of them re-renders every consumer of that context. Split providers by how often the underlying data changes and by which components actually need it.

Is it okay to use Context for frequently changing state, like form input values?

Generally no. Context works best for values that change infrequently relative to how often they're read, like a logged-in user or a theme. State that changes on every keystroke, like form fields, is usually better handled with local component state or a form library, since routing every keystroke through Context re-renders every consumer on every character typed.

How do I test a component that consumes Context?

Wrap it in the relevant provider in your test, or write a small test-only provider that supplies a fixed value. Testing the custom hook (`useAuth`, `useTheme`) in isolation with `renderHook` and a wrapper is usually cleaner than rendering the full component tree just to exercise the context logic.

Related articles

Avoiding Unnecessary Re-Renders — Aman Kumar Singh
useMemo and useCallback, When They Help — Aman Kumar Singh
React Fundamentals for Professionals — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.