React Basics #12: useContext
Last time we learned the pattern of lifting state up to the common parent so siblings can share data. It’s a useful tool, but a problem appears as the component tree gets deeper. This time we’ll cover that problem and its solution: Context.
The prop drilling problem #
Imagine the following component tree.
App (state: user)
└── Layout
└── Sidebar
└── ProfileMenu
└── UserAvatar (needs the user info here)The user state lives in App, but the actual consumer is UserAvatar, deep down. The components in between — Layout, Sidebar, ProfileMenu — don’t care about the user, but they have to receive props just to pass them down.
<Layout user={user}>
<Sidebar user={user}>
<ProfileMenu user={user}>
<UserAvatar user={user} />
</ProfileMenu>
</Sidebar>
</Layout>This situation — middle components receiving unrelated props just to forward them down — is called prop drilling. As the depth grows or the number of values to forward increases, the code gets messy fast.
To solve this, React provides a tool called the Context API.
The idea behind Context #
The core idea of Context is simple.
Provide data somewhere in the component tree, and any descendant at any depth can subscribe to it directly.
Data essentially teleports from up high to down low without going through middle components.
The 3 steps of using Context #
You use Context in three steps.
- Create the context — make one with
createContext - Provide — wrap part of the tree with
<Context.Provider value={...}>to supply data - Subscribe — call
useContext(Context)from a child component to consume the value
Let’s look at code. We’ll solve the user example above with Context.
Step 1 — Create the context #
import { createContext } from 'react';
export const UserContext = createContext(null);The value passed to createContext is a default value. It’s used when useContext is called outside any Provider.
Step 2 — Provide via Provider #
import { useState } from 'react';
import { UserContext } from './UserContext';
import Layout from './Layout';
function App() {
const [user, setUser] = useState({ name: 'Alice', email: 'alice@example.com' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
export default App;Every descendant component inside the area wrapped by UserContext.Provider can read the value. The middle components no longer need to receive user as a prop.
import Sidebar from './Sidebar';
function Layout() {
return (
<div>
<Sidebar />
</div>
);
}
export default Layout;import ProfileMenu from './ProfileMenu';
function Sidebar() {
return (
<aside>
<ProfileMenu />
</aside>
);
}
export default Sidebar;Layout, Sidebar, and ProfileMenu don’t have to know anything about the user. Much cleaner.
Step 3 — Subscribe with useContext #
import { useContext } from 'react';
import { UserContext } from './UserContext';
function UserAvatar() {
const user = useContext(UserContext);
if (!user) return <p>You need to log in.</p>;
return (
<div>
<p>{user.name}</p>
<p>{user.email}</p>
</div>
);
}
export default UserAvatar;Calling useContext(UserContext) returns the value provided by the nearest UserContext.Provider. You receive the value in one shot, without going through the middle.
Providing both value and functions #
A very common pattern is to bundle the state and its setter (or update functions) together in a Context object. That way descendants can both read and modify.
import { createContext } from 'react';
export const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});import { useState, useCallback } from 'react';
import { ThemeContext } from './ThemeContext';
import Page from './Page';
function App() {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<Page />
</ThemeContext.Provider>
);
}import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button onClick={toggleTheme}>
Current theme: {theme} (click to switch)
</button>
);
}
export default ThemeToggle;Descendant components destructure theme (the current value) and toggleTheme (the function to change it) together. Thanks to this pattern, a single Context can expose “shared state and how to manipulate it” all at once.
Wrapping the Provider in a component #
As Context usage grows, extracting the Provider itself into a separate component keeps things clean. It also gathers the state-management logic in one place.
import { useState, useCallback } from 'react';
import { ThemeContext } from './ThemeContext';
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export default ThemeProvider;import ThemeProvider from './ThemeProvider';
import Page from './Page';
function App() {
return (
<ThemeProvider>
<Page />
</ThemeProvider>
);
}App is much simpler. All theme-related logic is encapsulated in ThemeProvider, making it easier to reuse elsewhere.
Wrapping with a custom hook #
Importing the Context directly with useContext(ThemeContext) every time can be a slight nuisance. For frequently used contexts, wrapping them in a custom hook is a common pattern that improves ergonomics.
import { createContext, useContext } from 'react';
export const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
export function useTheme() {
return useContext(ThemeContext);
}import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
// ...
}The consumer code is shorter. We’ll cover custom hooks in more detail in the next article (#13).
Don’t overuse Context #
Context is powerful, but it isn’t a tool for every situation. Keep the following in mind.
1. For simple prop passing, props are better #
For one or two parent-child levels, props are far more explicit and easier to trace. Context shines when the depth is really deep (3–4 levels or more) or when many branches consume the same data.
2. When the Provider’s value changes, all subscribers below re-render #
Context binds all descendants together, so frequent value changes trigger broad re-renders. Handling frequently changing data (like mouse coordinates) via Context can cause performance problems.
3. Context is not a global state library #
Context is “a data passage,” not a sophisticated state management tool in itself. For complex app-wide state (global user info + notifications + cart + settings + …), libraries built for the job — like Zustand, Redux Toolkit, or Jotai — fit better. Context is well-suited for small-scope shared state or “rarely changing” global data such as theme, language, and authentication state.
Try it yourself #
Let’s manage a theme (light/dark) via Context, where two child components share the same theme state.
src/ThemeContext.js:
import { createContext, useContext, useState, useCallback } from 'react';
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}src/Card.jsx:
import { useTheme } from './ThemeContext';
function Card({ children }) {
const { theme } = useTheme();
const styles = {
background: theme === 'light' ? '#fff' : '#222',
color: theme === 'light' ? '#000' : '#fff',
padding: '16px',
border: '1px solid #999',
borderRadius: '8px',
margin: '8px 0',
};
return <div style={styles}>{children}</div>;
}
export default Card;src/ThemeToggle.jsx:
import { useTheme } from './ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current: {theme === 'light' ? '☀ Light' : '🌙 Dark'} (click to switch)
</button>
);
}
export default ThemeToggle;src/App.jsx:
import { ThemeProvider } from './ThemeContext';
import Card from './Card';
import ThemeToggle from './ThemeToggle';
function App() {
return (
<ThemeProvider>
<ThemeToggle />
<Card>
<h2>First card</h2>
<p>Colors change when you switch themes.</p>
</Card>
<Card>
<h2>Second card</h2>
<p>Both cards share the same theme.</p>
</Card>
</ThemeProvider>
);
}
export default App;Press the button and both cards’ colors change at the same time. Card and ThemeToggle don’t know about each other, the parent doesn’t broker any props between them, and yet they share the same theme state. Without prop drilling, anywhere in the tree can access the same data.
Wrapping up #
In this article we covered the prop drilling problem and its solution, the Context API. To summarize:
- Prop drilling — middle components receive unrelated props just to forward them
- Context is a “passage” that lets descendants directly read a value provided somewhere up the tree
- Three-step usage:
createContext→<Provider value={...}>→useContext - The pattern of bundling value and setter in an object is common
- Extract Provider logic into its own component and wrap consumption in a custom hook for cleanliness
- Plain props are better for shallow forwarding; Context is unsuitable for frequently changing data
This wraps up batch 2 (#9–#12). With forms, useEffect, lifting state up, and Context covered, you now have most of the patterns needed to build a small real-world app from start to finish.
In the next article, “React Basics #13: Custom Hooks,” we’ll cover the most elegant tool for sharing logic between components — custom hooks. The useTheme we used briefly in this article was actually one example of a custom hook. Next time we’ll cover them in earnest.