Context API and Its Trade-offs
Context Solves One Problem, Creates Another
React Context sounds perfect on paper: pass data through the component tree without threading props through every level. No more prop drilling — no more passing a value through 5 intermediate components that couldn't care less about it.
But here's the trade-off nobody mentions in the tutorials: every consumer re-renders when the provider value changes, regardless of which part of the value they actually use. And that's where the trouble starts.
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Header />
<Main />
<Footer />
</ThemeContext.Provider>
);
}
function DeepChild() {
const theme = useContext(ThemeContext);
return <div className={`card card--${theme}`}>Content</div>;
}
Think of Context as a radio broadcast. The Provider is the radio station, and useContext is a receiver tuned to that station. When the station broadcasts a new signal (value changes), every receiver picks it up and activates (re-renders). There is no way to tune into just part of the broadcast — if the value changes at all, every consumer reacts. This is efficient for infrequently changing values (theme, locale, auth) but wasteful for rapidly changing values (mouse position, input text).
Creating and Using Context
// 1. Create the context with a default value
const UserContext = createContext(null);
// 2. Provider wraps the subtree that needs access
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Dashboard />
</UserContext.Provider>
);
}
// 3. Consumers read the value with useContext
function UserAvatar() {
const { user } = useContext(UserContext);
if (!user) return <GuestAvatar />;
return <img src={user.avatarUrl} alt={user.name} />;
}
The default value (null in createContext(null)) is used only when a component calls useContext without a matching Provider above it in the tree. This is primarily useful for testing or standalone component usage.
The Re-render Problem
Let's look at the problem most Context tutorials conveniently skip over:
const AppContext = createContext();
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const [theme, setTheme] = useState('dark');
// NEW object on every render!
const value = { user, theme, setUser, setTheme };
return (
<AppContext.Provider value={value}>
<Sidebar /> {/* Only uses theme */}
<MainContent /> {/* Only uses user */}
</AppContext.Provider>
);
}
Every time App re-renders (any state change), value is a new object reference. Every consumer of AppContext re-renders — even if only user changed and Sidebar only reads theme.
Creating a new object in the Provider's value prop forces all consumers to re-render on every parent render, even when nothing in the context actually changed. This is the most common Context performance bug. The object reference changes, so React assumes the value changed.
Fix 1: Memoize the Value
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const [theme, setTheme] = useState('dark');
const value = useMemo(
() => ({ user, theme, setUser, setTheme }),
[user, theme]
);
return (
<AppContext.Provider value={value}>
<Sidebar />
<MainContent />
</AppContext.Provider>
);
}
Now the value object only changes when user or theme actually changes. But consumers that use user still re-render when only theme changes — because they consume the entire context.
Fix 2: Split Into Separate Contexts
const UserContext = createContext(null);
const ThemeContext = createContext('light');
function App() {
const [user, setUser] = useState({ name: 'Alice' });
const [theme, setTheme] = useState('dark');
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<Sidebar /> {/* useContext(ThemeContext) — only re-renders on theme change */}
<MainContent /> {/* useContext(UserContext) — only re-renders on user change */}
</ThemeContext.Provider>
</UserContext.Provider>
);
}
This is the recommended pattern. Each consumer subscribes only to the data it needs.
Composition: The Alternative to Context
Before you reach for Context, ask yourself this: can you solve the problem with composition instead?
// BEFORE: Prop drilling through 3 levels
function App() {
const [user, setUser] = useState(currentUser);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserAvatar user={user} />;
}
// AFTER: Composition — pass the component as children
function App() {
const [user, setUser] = useState(currentUser);
return (
<Layout>
<Sidebar>
<UserAvatar user={user} />
</Sidebar>
</Layout>
);
}
// Layout and Sidebar do not need to know about user at all.
// They just render {children}.
Why composition often beats Context
Composition moves the component creation to the place where the data lives. Layout and Sidebar become generic containers that accept any content via children. They are simpler, more reusable, and do not re-render when user changes (because children is an element created by the parent — its identity is stable). Context is powerful but adds coupling: every consumer is tied to a specific Provider shape. Composition keeps components independent.
When to Use Context
Context is the right choice for:
- Infrequently changing global values: theme, locale, auth status
- Deeply nested access: values needed by many components at many levels
- Dependency injection: providing implementations (API clients, feature flags)
Context is the wrong choice for:
- Rapidly changing values: mouse position, scroll offset, keystroke input
- Large objects: entire app state in one context (Redux-style)
- Simple prop passing: 2-3 levels deep — just pass the prop
Production Scenario: Theme Provider Pattern
const ThemeContext = createContext({
colors: { primary: '#000', background: '#fff' },
toggleTheme: () => {},
});
function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(false);
const theme = useMemo(() => ({
colors: isDark
? { primary: '#fff', background: '#111' }
: { primary: '#000', background: '#fff' },
toggleTheme: () => setIsDark(d => !d),
}), [isDark]);
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for type safety and error handling
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
-
Wrong: Putting everything in one giant context:
{ user, theme, settings, cart, notifications }Right: Split into separate focused contexts: UserContext, ThemeContext, CartContext -
Wrong: Creating a new value object on every render:
value={{ user, theme }}Right: Memoize:const value = useMemo(() => ({ user, theme }), [user, theme]) -
Wrong: Using Context for frequently changing state (mouse position, input text) Right: Use state colocation, refs, or external stores (Zustand, useSyncExternalStore)
-
Wrong: Not providing a custom hook for context access Right: Create useMyContext() that includes the null check and error message
- 1Context is a broadcast — every consumer re-renders when the value changes
- 2Split large contexts into focused ones so consumers subscribe to only what they need
- 3Memoize Provider values to prevent unnecessary re-renders from new object references
- 4Try composition before Context — passing components as children often eliminates prop drilling
- 5Context is for infrequent changes (theme, locale, auth) — not for rapidly changing state