ReactContext Best Practices

Context Best Practices

The Context API is powerful, but it is easy to misuse in ways that create hard-to-debug performance problems or fragile component trees. These five practices separate context code that scales from context code that causes headaches.

1. Split Contexts by Update Frequency

Every consumer re-renders when the context value changes. If you put fast-changing UI state (e.g. a hover position or search query) in the same context as slow-changing config (e.g. the current user), the slow consumers re-render on every fast update — even though they do not care about the fast data.

The fix is to split contexts by how often their values change:

JSX
// ✗ Bad — one mega-context mixes stable config with volatile UI state
const AppContext = createContext(null)

function AppProvider({ children }) {
  const [user, setUser]         = useState(null)     // changes rarely
  const [locale, setLocale]     = useState('en')     // changes rarely
  const [searchQuery, setSearchQuery] = useState('') // changes on every keystroke

  // A search keystroke re-renders every consumer of AppContext,
  // including components that only care about user or locale.
  return (
    <AppContext.Provider value={{ user, locale, searchQuery, setSearchQuery }}>
      {children}
    </AppContext.Provider>
  )
}

// ✓ Good — separate contexts by update frequency
const UserContext   = createContext(null)   // changes: login/logout
const LocaleContext = createContext('en')   // changes: language switch
const SearchContext = createContext(null)   // changes: every keystroke

function AppProvider({ children }) {
  const [user, setUser]             = useState(null)
  const [locale, setLocale]         = useState('en')
  const [searchQuery, setSearch]    = useState('')

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <LocaleContext.Provider value={{ locale, setLocale }}>
        <SearchContext.Provider value={{ searchQuery, setSearch }}>
          {children}
        </SearchContext.Provider>
      </LocaleContext.Provider>
    </UserContext.Provider>
  )
}
// Now a search keystroke ONLY re-renders SearchContext consumers.
2. Place the Provider as Close to Consumers as Possible

A common beginner mistake is wrapping the entire app in every Provider, even when only a small subtree actually needs the context. This unnecessarily expands the re-render blast radius and makes the code harder to reason about.

Push the Provider down the tree until it is as close to its consumers as possible:

JSX
// ✗ Bad — ModalContext wraps the entire app, but only the modal area uses it
function App() {
  return (
    <ModalContext.Provider value={...}>
      <Header />         {/* never uses ModalContext */}
      <Sidebar />        {/* never uses ModalContext */}
      <MainContent />    {/* this section uses ModalContext */}
      <Footer />         {/* never uses ModalContext */}
    </ModalContext.Provider>
  )
}

// ✓ Good — Provider wraps only the subtree that needs it
function App() {
  return (
    <>
      <Header />
      <Sidebar />
      <ModalContext.Provider value={...}>
        <MainContent />  {/* the only subtree that uses ModalContext */}
      </ModalContext.Provider>
      <Footer />
    </>
  )
}
Note
The rule of least privilege applies to Providers too. A Provider that wraps only its consumers limits which components can accidentally become coupled to that context.
3. Create a Custom Hook to Wrap useContext

Calling useContext(SomeContext) directly in consumers works, but it has two weaknesses: it forces every consumer to import both the hook and the context object, and it silently returns undefined (the default value) if someone uses it outside a Provider.

Wrapping useContext in a custom hook solves both problems at once:

JSX
// theme-context.tsx
import { createContext, useContext, useState, useMemo } from 'react'

const ThemeContext = createContext(null)   // null signals "must be inside Provider"

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  const value = useMemo(() => ({ theme, setTheme }), [theme])

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

// ✓ Custom hook: single import, built-in error boundary
export function useTheme() {
  const context = useContext(ThemeContext)

  if (context === null) {
    throw new Error('useTheme must be used inside a <ThemeProvider>.')
  }

  return context
}

// Consumer — one import, clear error if misused
import { useTheme } from './theme-context'

function DarkModeToggle() {
  const { theme, setTheme } = useTheme()   // throws if no Provider above it

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      {theme === 'light' ? '🌙 Dark' : '☀️ Light'}
    </button>
  )
}
Tip
The thrown error message is invaluable during development. Without it, a missing Provider silently returns undefined and causes a cryptic "Cannot read properties of undefined" error far from the actual mistake.
4. Memoize the Context Value

When the Provider component re-renders for any reason (e.g. its parent re-renders), the value object is recreated. Because React uses reference equality (Object.is) to decide if the context value changed, a new object — even with the same contents — triggers re-renders in all consumers. Wrap the value with useMemo to prevent this:

JSX
import { createContext, useContext, useState, useMemo, useCallback } from 'react'

const AuthContext = createContext(null)

function AuthProvider({ children }) {
  const [user, setUser] = useState(null)

  // ✓ Memoize the logout function so its reference is stable
  const logout = useCallback(() => {
    setUser(null)
    localStorage.removeItem('token')
  }, [])

  // ✓ Memoize the context object — only changes when 'user' changes
  const value = useMemo(() => ({ user, setUser, logout }), [user, logout])

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

// Without useMemo:
// Every render of AuthProvider creates a new value object.
// Every useContext(AuthContext) consumer re-renders — even if user didn't change.

// With useMemo:
// value is the same object reference as long as 'user' and 'logout' are unchanged.
// Consumers only re-render when the user actually changes.
Warning
useMemo is not magic — it only helps when the Provider's parent re-renders for reasons unrelated to the context state. If the context state itself changes, consumers must re-render (that is the whole point). useMemo just prevents spurious re-renders caused by object identity.
5. Keep Context Focused — Avoid the Mega-Context Anti-Pattern

It is tempting to create one GlobalContext and stuff everything into it — user, theme, locale, cart items, notifications, modal state… This creates a god object that is hard to maintain and forces unrelated components to couple to a single giant context:

JSX
// ✗ Anti-pattern: everything in one context
const GlobalContext = createContext(null)

function GlobalProvider({ children }) {
  const [user, setUser]               = useState(null)
  const [theme, setTheme]             = useState('light')
  const [locale, setLocale]           = useState('en')
  const [cartItems, setCartItems]     = useState([])
  const [notifications, setNotify]    = useState([])
  const [modalOpen, setModalOpen]     = useState(false)

  // Adding an item to the cart re-renders the entire component tree
  // that subscribes to GlobalContext, including the Header, Sidebar,
  // Footer — everything — even though they don't care about cart.
  return (
    <GlobalContext.Provider value={{
      user, setUser, theme, setTheme, locale, setLocale,
      cartItems, setCartItems, notifications, setNotify,
      modalOpen, setModalOpen,
    }}>
      {children}
    </GlobalContext.Provider>
  )
}

// ✓ Better: small, focused contexts
// src/contexts/auth-context.tsx
// src/contexts/theme-context.tsx
// src/contexts/locale-context.tsx
// src/contexts/cart-context.tsx
// Each context only re-renders its own consumers.

A good context has a single responsibility: it owns one cohesive slice of application state. A simple litmus test — if removing one field from the context would break only one consumer, that field belongs in its own context (or just local state).

Good vs Bad Context Design at a Glance
  • Good: AuthContext — owns user, login, logout only.

  • Good: ThemeContext — owns theme and toggleTheme only.

  • Bad: AppContext — owns user, theme, locale, cart, modals, and everything else.

  • Good: Provider placed just above its consumers in the tree.

  • Bad: Provider placed at the app root when only one page needs it.

  • Good: Custom hook (useTheme) wraps useContext with an error check.

  • Bad: Calling useContext(ThemeContext) directly in every consumer.

  • Good: useMemo wraps the context value object in the Provider.

  • Bad: Passing value={{ theme, setTheme }} as an inline object literal.

Note
These five practices are cumulative. A context that splits by frequency, is placed close to consumers, exposes a custom hook, memoizes its value, and stays focused is one that will serve you well as your application grows.