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:
// ✗ 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:
// ✗ 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 />
</>
)
}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:
// 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>
)
}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:
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.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:
// ✗ 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— ownsuser,login,logoutonly.Good:
ThemeContext— ownsthemeandtoggleThemeonly.Bad:
AppContext— ownsuser,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) wrapsuseContextwith an error check.Bad: Calling
useContext(ThemeContext)directly in every consumer.Good:
useMemowraps the context value object in the Provider.Bad: Passing
value={{ theme, setTheme }}as an inline object literal.