useContext in React: Solving Prop Drilling with Real Examples
You've probably experienced prop drilling—passing state through multiple components that don't actually need it, just to reach a deeply nested component that does. It's tedious, error-prone, and makes refactoring a nightmare. React's useContext Hook offers a cleaner solution. Instead of threading props down through every layer, you share state directly with the components that need it. In this guide, you'll learn how to build providers, consume context, and structure your app for scalability.
Table of Contents
- Understanding Prop Drilling and Its Problems
- What is useContext and the Context API?
- Creating and Providing Context
- Consuming Context with useContext
- useContext vs use() Hook (React 19)
- Typing Context in TypeScript
- Real-World Application: Theme Provider
- Common Patterns and Best Practices
- FAQ
Understanding Prop Drilling and Its Problems
What is Prop Drilling?
Prop drilling occurs when you pass data through multiple component layers—even through components that don't need the data—just to reach a deeply nested component that does. Here's a simple example:
// App (has theme state)
// └── Header (doesn't need theme)
// └── Navbar (doesn't need theme)
// └── UserMenu (needs theme)
interface AppProps {
children?: ReactNode;
}
export function App({ children }: AppProps) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<div className={`app ${theme}`}>
<Header theme={theme} setTheme={setTheme} />
{children}
</div>
);
}
// Header receives theme just to pass it down
interface HeaderProps {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export function Header({ theme, setTheme }: HeaderProps) {
return <Navbar theme={theme} setTheme={setTheme} />;
}
// Navbar receives theme just to pass it down
interface NavbarProps {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export function Navbar({ theme, setTheme }: NavbarProps) {
return <UserMenu theme={theme} setTheme={setTheme} />;
}
// UserMenu finally uses theme
interface UserMenuProps {
theme: 'light' | 'dark';
setTheme: (theme: 'light' | 'dark') => void;
}
export function UserMenu({ theme, setTheme }: UserMenuProps) {
return (
<div>
Current theme: {theme}
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export function App({ children }) {
const [theme, setTheme] = useState('light');
return (
<div className={`app ${theme}`}>
<Header theme={theme} setTheme={setTheme} />
{children}
</div>
);
}
export function Header({ theme, setTheme }) {
return <Navbar theme={theme} setTheme={setTheme} />;
}
export function Navbar({ theme, setTheme }) {
return <UserMenu theme={theme} setTheme={setTheme} />;
}
export function UserMenu({ theme, setTheme }) {
return (
<div>
Current theme: {theme}
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
Why Prop Drilling Is Problematic
1. Middle components are tightly coupled - Header and Navbar depend on theme props they don't use. This makes them harder to reuse in other contexts.
2. Refactoring becomes expensive - If you rename a prop or change its shape, you must update every component in the chain.
3. Props clutter component signatures - You can't see at a glance what a component actually does when its interface is filled with pass-through props.
4. Component reusability decreases - Want to use Navbar somewhere else? You must provide all its props, even the ones it just passes down.
For simple, one-or-two-level hierarchies, prop drilling is acceptable. But in larger apps—especially enterprise applications (like those at Alibaba or ByteDance)—it becomes a maintenance burden.
What is useContext and the Context API?
How Context Solves Prop Drilling
React's Context API lets you create a "mailbox" that any component can access without passing data through props. Once you set up a context and a provider, any component nested below that provider can read and update the context value.
App (provides theme context)
├── Header (no props needed)
│ └── Navbar (no props needed)
│ └── UserMenu (reads from context)
└── Sidebar (reads from context)
The Three Steps of Using Context
- Create a context object with
createContext() - Provide the context value in a parent component
- Consume the context in child components with
useContext()
Creating and Providing Context
Step 1: Create the Context
Create a context file that defines the shape of your data:
import { createContext, ReactNode, useState } from 'react';
// Define the shape of the context value
export interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// Create the context with a default value (for better IDE support)
export const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {},
});
// Create a provider component to manage the context value
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
const value: ThemeContextType = {
theme,
toggleTheme,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
import { createContext, useState } from 'react';
export const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
const value = {
theme,
toggleTheme,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Key points:
- The context object is created once (at the top of the file)
- The provider component wraps children and manages the state
- The value object contains both state and functions to update it
Step 2: Wrap Your App with the Provider
In your root component, wrap the part of your app that needs context access:
import { ThemeProvider } from './context/ThemeContext';
import Header from './components/Header';
import MainContent from './components/MainContent';
export function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
);
}
export function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
);
}
Now, any component nested inside ThemeProvider can access the theme context.
Consuming Context with useContext
Using useContext in Components
Any component nested below the provider can access context using the useContext Hook:
import { useContext } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function UserMenu() {
// Access the context
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div className={`menu ${theme}`}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'}
</button>
</div>
);
}
import { useContext } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function UserMenu() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div className={`menu ${theme}`}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'}
</button>
</div>
);
}
Notice that UserMenu receives no props—it gets the theme directly from context. And if Header or Navbar are in between, they don't need to know about the context at all.
Multiple Components, One Context
The beauty of context is that multiple components at different levels can consume the same context without prop drilling:
import { useContext } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function Header() {
const { theme } = useContext(ThemeContext);
return <header className={theme}>Header</header>;
}
export function Footer() {
const { theme } = useContext(ThemeContext);
return <footer className={theme}>Footer</footer>;
}
export function Sidebar() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<aside className={theme}>
<button onClick={toggleTheme}>Toggle Theme</button>
</aside>
);
}
useContext vs use() Hook (React 19)
The New use() Hook
Starting with React 19, a new use() Hook provides more flexibility than useContext(). It can be used inside conditional blocks and loops (breaking the normal Rules of Hooks), making it more powerful.
import { use } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function UserMenu() {
// React 19+: use() is more flexible
const { theme, toggleTheme } = use(ThemeContext);
return (
<div className={`menu ${theme}`}>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
import { use } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function UserMenu() {
const { theme, toggleTheme } = use(ThemeContext);
return (
<div className={`menu ${theme}`}>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
When to Use Which?
| Hook | When to Use | Flexibility |
|---|---|---|
useContext() |
React 18 and below, traditional patterns | Less flexible (must be at top level) |
use() |
React 19+, dynamic scenarios | More flexible (can be conditional) |
Recommendation: If you're on React 19+, prefer use() for new code. If you need to support older versions, stick with useContext(). Both are valid.
Typing Context in TypeScript
Proper Type Inference
TypeScript needs to understand the context's structure. Always define an interface for your context value:
import { createContext, ReactNode, useState } from 'react';
// ✅ CORRECT: Define the context shape explicitly
interface UserContextType {
user: { id: string; name: string; email: string } | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const UserContext = createContext<UserContextType>({
user: null,
login: async () => {},
logout: () => {},
});
interface UserProviderProps {
children: ReactNode;
}
export function UserProvider({ children }: UserProviderProps) {
const [user, setUser] = useState<{ id: string; name: string; email: string } | null>(null);
const [loading, setLoading] = useState(false);
const login = async (email: string, password: string) => {
setLoading(true);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
setUser(data.user);
} finally {
setLoading(false);
}
};
const logout = () => {
setUser(null);
};
const value: UserContextType = { user, login, logout };
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
export const UserContext = createContext({
user: null,
login: async () => {},
logout: () => {},
});
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const login = async (email, password) => {
setLoading(true);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await response.json();
setUser(data.user);
} finally {
setLoading(false);
}
};
const logout = () => {
setUser(null);
};
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
Creating a Custom Hook for Context
A common pattern is to create a custom Hook that wraps useContext(), providing type safety and better error messages:
import { useContext } from 'react';
import { ThemeContext, ThemeContextType } from './ThemeContext';
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Now components use the custom Hook instead of useContext() directly:
export function UserMenu() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>Toggle {theme}</button>;
}
Real-World Application: Multi-Language Provider
Consider a common scenario in e-commerce apps (like Alibaba or ByteDance) where you need to support multiple languages. Here's a production-ready implementation:
import { createContext, ReactNode, useState, useCallback } from 'react';
type Language = 'en' | 'zh' | 'es' | 'fr';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
}
const translations: Record<Language, Record<string, string>> = {
en: {
'greeting': 'Hello',
'goodbye': 'Goodbye',
'login': 'Sign In',
'logout': 'Sign Out',
},
zh: {
'greeting': '你好',
'goodbye': '再见',
'login': '登录',
'logout': '退出',
},
es: {
'greeting': 'Hola',
'goodbye': 'Adiós',
'login': 'Iniciar sesión',
'logout': 'Cerrar sesión',
},
fr: {
'greeting': 'Bonjour',
'goodbye': 'Au revoir',
'login': "S'identifier",
'logout': 'Se déconnecter',
},
};
export const LanguageContext = createContext<LanguageContextType>({
language: 'en',
setLanguage: () => {},
t: (key: string) => key,
});
interface LanguageProviderProps {
children: ReactNode;
initialLanguage?: Language;
}
export function LanguageProvider({
children,
initialLanguage = 'en'
}: LanguageProviderProps) {
const [language, setLanguage] = useState<Language>(initialLanguage);
// Memoize the translation function to prevent unnecessary re-renders
const t = useCallback((key: string): string => {
return translations[language][key] || key;
}, [language]);
const value: LanguageContextType = {
language,
setLanguage,
t,
};
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
// Custom Hook for using language context
export function useLanguage(): LanguageContextType {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}
import { createContext, useState, useCallback, useContext } from 'react';
const translations = {
en: {
'greeting': 'Hello',
'goodbye': 'Goodbye',
'login': 'Sign In',
'logout': 'Sign Out',
},
zh: {
'greeting': '你好',
'goodbye': '再见',
'login': '登录',
'logout': '退出',
},
es: {
'greeting': 'Hola',
'goodbye': 'Adiós',
'login': 'Iniciar sesión',
'logout': 'Cerrar sesión',
},
fr: {
'greeting': 'Bonjour',
'goodbye': 'Au revoir',
'login': "S'identifier",
'logout': 'Se déconnecter',
},
};
export const LanguageContext = createContext({
language: 'en',
setLanguage: () => {},
t: (key) => key,
});
export function LanguageProvider({ children, initialLanguage = 'en' }) {
const [language, setLanguage] = useState(initialLanguage);
const t = useCallback((key) => {
return translations[language][key] || key;
}, [language]);
const value = {
language,
setLanguage,
t,
};
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
}
Usage:
import { useLanguage } from './context/LanguageContext';
export function Header() {
const { language, setLanguage, t } = useLanguage();
return (
<header>
<h1>{t('greeting')}</h1>
<select value={language} onChange={(e) => setLanguage(e.target.value as Language)}>
<option value="en">English</option>
<option value="zh">中文</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
</header>
);
}
export function AuthButtons() {
const { t } = useLanguage();
return (
<div>
<button>{t('login')}</button>
<button>{t('logout')}</button>
</div>
);
}
Common Patterns and Best Practices
1. Split Context by Concern
Don't create one giant context with everything. Instead, separate contexts by concern:
// ❌ WRONG: Too much in one context
export const AppContext = createContext({
theme: 'light',
toggleTheme: () => {},
user: null,
login: async () => {},
language: 'en',
setLanguage: () => {},
notifications: [],
addNotification: () => {},
});
// ✅ CORRECT: Separate contexts
export const ThemeContext = createContext({ /* theme only */ });
export const UserContext = createContext({ /* auth only */ });
export const LanguageContext = createContext({ /* i18n only */ });
export const NotificationContext = createContext({ /* notifications only */ });
2. Use Provider Composition
Combine multiple providers at the root level:
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<UserProvider>
<LanguageProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</LanguageProvider>
</UserProvider>
</ThemeProvider>
);
}
// In App.tsx
export function App() {
return (
<Providers>
<Header />
<MainContent />
<Footer />
</Providers>
);
}
3. Memoize Context Values
Context updates cause all consumers to re-render. Memoize expensive values or functions:
import { useMemo, useCallback } from 'react';
export function UserProvider({ children }: UserProviderProps) {
const [user, setUser] = useState(null);
// ✅ Memoize callback to prevent unnecessary renders
const login = useCallback(async (email: string, password: string) => {
const response = await fetch('/api/login', { /* ... */ });
const data = await response.json();
setUser(data.user);
}, []);
// ✅ Memoize context value object
const value = useMemo(() => ({
user,
login,
logout: () => setUser(null),
}), [user, login]);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
4. Handle Missing Context Gracefully
Always check if context is available before using it:
export function useUser() {
const context = useContext(UserContext);
// Throw helpful error if used outside provider
if (context === undefined) {
throw new Error(
'useUser must be used within a UserProvider. ' +
'Make sure your component is wrapped in <UserProvider>.'
);
}
return context;
}
FAQ
Q: When should I use Context API vs prop drilling?
A: Use prop drilling for 1-2 levels of nesting. The overhead of context setup isn't worth it for simple cases. Use Context API when:
- You're passing props through 3+ levels
- Multiple parts of your app need the same data
- The data updates frequently and many components need to react
- You want to improve reusability of intermediate components
Q: Does context cause all child components to re-render when the context value changes?
A: Yes, by default. When context value changes, all components consuming that context re-render. This is usually fine for small apps, but in large apps you might want to split contexts by update frequency (fast-changing vs. stable values) or use state management libraries like Zustand.
Q: Can I use multiple contexts together?
A: Absolutely! It's a common pattern. Wrap your app with multiple providers and consume multiple contexts in components. Just avoid creating a "god context" with everything—separate by concern.
Q: What's the difference between creating context with null vs. a default value?
A: Using null as the default requires type checking in components. Using a default value (like empty functions) provides better IDE autocomplete but is less type-safe. Choose based on your needs. For production, I recommend strongly typed contexts with custom hooks that throw errors if used outside providers.
Q: Is useContext performance-efficient for large apps?
A: Context works well for small-to-medium apps. For large apps with frequent updates, consider:
- Splitting contexts by update frequency
- Using
useMemoto memoize context values - Using specialized state management libraries (Zustand, Redux)
- Using component-level state for local concerns
Related Articles
- useState Hook: Managing Component State
- useReducer: Complex State Management
- Building Custom Hooks: Reuse Effect Logic
- Solving Prop Drilling in React
Questions? Share your context patterns and use cases in the comments below. How do you structure context in your large-scale applications? What challenges have you faced with Context API? I'd love to hear about real-world scenarios and solutions.
Google AdSense Placeholder
CONTENT SLOT