React Context API provides a way to pass data through the component tree without having to pass props manually at every level. While powerful, it comes with its own set of patterns and anti-patterns. Let's explore how to use Context effectively.
Understanding Context Basics
Context is designed for sharing "global" data that can be considered "global" for a tree of React components, like the current authenticated user, theme, or preferred language. It's often overused for problems that have simpler solutions.
When to Use Context
Use Context when: multiple components at different nesting levels need access to the same data, you want to avoid prop drilling through many layers, and the data changes infrequently. Don't use Context when: a simple prop would suffice, data changes frequently causing many re-renders, or for complex state management needs better served by dedicated libraries.
Creating Context
The createContext function creates a Context object. Both the Context object and its Provider component are exported for use throughout the application.
const ThemeContext = createContext<{
theme: 'light' | 'dark';
toggleTheme: () => void;
} | undefined>(undefined);Notice we set the default value to undefined. This ensures TypeScript will catch cases where Context is used outside a Provider.
Provider Pattern
Wrap your application or component tree with the Provider to make the context value available to all nested components.
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}Optimizing Provider Re-renders
The Provider component re-renders all consumers whenever the value prop changes. Memoize the value to prevent unnecessary re-renders.
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light')
}), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}Consumer Pattern
Consumers access the context value using the useContext Hook.
Custom Hook Pattern
Create a custom hook for consuming context. This provides a clean API and ensures type safety.
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}The error check ensures the hook is used within a Provider, catching bugs early.
Separation of Concerns Pattern
Split context when you have values that change at different rates. This prevents components from re-rendering when unrelated values change.
// Static or rarely changing values
const ConfigContext = createContext({});
// Frequently changing values
const UserContext = createContext({});Components consuming ConfigContext won't re-render when UserContext changes, and vice versa.
Compound Components Pattern
Context enables the compound components pattern, where components work together through implicit state sharing.
const TabsContext = createContext(null);
function Tabs({ children, defaultValue }) {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div role="tablist">{children}</div>;
}
function Tab({ children, value }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={activeTab === value}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div>{children}</div>;
}
function TabPanel({ children, value }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
}State Reducer Pattern
For complex context logic, use the reducer pattern. This keeps state updates predictable and testable.
type State = { count: number };
type Action = { type: 'increment' } | { type: 'decrement' };
const CounterContext = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
} | undefined>(undefined);
function counterReducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
const value = { state, dispatch };
return (
<CounterContext.Provider value={value}>
{children}
</CounterContext.Provider>
);
}Context Composition
Compose multiple contexts to build complex feature sets. Each context handles a specific concern.
function AppProviders({ children }) {
return (
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
{children}
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
);
}Context Selectors Pattern
To prevent unnecessary re-renders, create context consumers that select only the data they need.
function useContextSelector<T>(
context: Context<T>,
selector: (value: T) => unknown
) {
const value = useContext(context);
return selector(value);
}
// Usage
const theme = useContextSelector(ThemeContext, ctx => ctx.theme);React 18+ provides useSyncExternalStore and useContext for similar optimizations, but custom selector patterns work in earlier versions.
Context with Server Components
With React Server Components, you can create contexts that work across server and client boundaries.
"use client";
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
export function useTheme() {
return useContext(ThemeContext);
}
export function ThemeProvider({
children,
initialTheme
}: {
children: React.ReactNode;
initialTheme: string;
}) {
return (
<ThemeContext.Provider value={initialTheme}>
{children}
</ThemeContext.Provider>
);
}Testing Context
Test context-consuming components by wrapping them with the necessary providers.
function renderWithProviders(
ui: React.ReactElement,
{ theme = 'light' } = {}
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider initialTheme={theme}>
{children}
</ThemeProvider>
);
}
return render(ui, { wrapper: Wrapper });
}
test('consumes theme context', () => {
renderWithProviders(<ThemeDisplay />, { theme: 'dark' });
expect(screen.getByText(/dark/i)).toBeInTheDocument();
});Performance Considerations
Context has performance implications you should understand.
Unnecessary Re-renders
All consumers re-render when context value changes. Mitigate this by: splitting contexts by update frequency, memoizing context values, and using selectors to consume only needed data.
Object Reference Stability
Always memoize context values to prevent reference changes that trigger unnecessary re-renders.
// ❌ Bad: New object every render
<Context.Provider value={{ data, update }} />
// ✅ Good: Stable reference
const value = useMemo(() => ({ data, update }), [data]);
<Context.Provider value={value} />Common Anti-Patterns
Avoid these common mistakes: putting everything in context causing performance issues, updating context too frequently from many sources, creating deeply nested provider components, and using context for complex state better handled by dedicated libraries.
Context vs External Libraries
Context is great for global UI state, but specialized libraries often serve better for specific needs: Redux Toolkit for complex global state, React Query for server state, Zustand or Jotai for simple global state, and React Hook Form for form state.
Best Practices
- 1Create contexts for specific concerns, not one giant context
- 2Use TypeScript for type safety
- 3Provide custom hooks for consuming context
- 4Memoize context values
- 5Split contexts by update frequency
- 6Document context usage and expected values
- 7Use error boundaries to catch context errors gracefully
Conclusion
React Context API is a powerful tool for sharing state across your component tree. Understanding when to use it, how to structure it, and how to optimize it will help you build more maintainable applications.
Use context sparingly and for appropriate use cases. Split contexts by concern, optimize providers with memoization, and create custom hooks for clean APIs. When used correctly, context eliminates prop drilling and provides a clean way to share global state.
Remember: just because you can use context for everything doesn't mean you should. Choose the right tool for the job, and use context where it shines: sharing truly global state that many components need.