useContext — when it fits and when it doesn't
The Context API that resolves prop drilling. Three-step usage, value-separation pattern, and the boundary against external state libraries.
Chapter 11 covered the pattern of lifting state up to a common parent so sibling components can share data. It is a great tool, but it runs into one problem as the component tree grows deeper. This chapter covers that problem and its solution — Context — and also marks the boundary against the external state libraries that lie beyond Context’s limits.
The prop drilling problem #
Imagine the following component tree.
App (state: user)
└── Layout
└── Sidebar
└── ProfileMenu
└── UserAvatar (needs the user data here)The user state lives in App, but the component that actually uses it is UserAvatar, deep below. The components in between — Layout, Sidebar, ProfileMenu — don’t care about user, but they have to receive the prop just to pass it down.
<Layout user={user}>
<Sidebar user={user}>
<ProfileMenu user={user}>
<UserAvatar user={user} />
</ProfileMenu>
</Sidebar>
</Layout>When middle components receive props they don’t use, only to pass them further down, it is called prop drilling. As the depth grows or the number of values to pass grows, the code gets messy quickly.
React offers the Context API to solve this.
The idea behind Context #
Context’s core idea is simple.
If you “supply” data somewhere in the component tree, any descendant at any depth can “subscribe” to it and use it directly.
It is as if the data teleports from the top down without going through the middle components.
Three steps for using Context #
You use Context in three steps.
- Create the Context — with
createContext - Supply — wrap a part of the tree with
<Context.Provider value={...}>to provide the data - Subscribe — pull the value with
useContext(Context)in a descendant component
Let’s apply this to the user example above.
Step 1 — create the Context #
import { createContext } from 'react';
export const UserContext = createContext(null);The value you pass to createContext is the default. It is used when useContext is called outside any Provider.
Step 2 — supply via Provider #
import { useState } from 'react';
import { UserContext } from './UserContext';
import Layout from './Layout';
function App() {
const [user, setUser] = useState({ name: 'Cheolsu', email: 'cheolsu@example.com' });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
export default App;Every descendant component inside the UserContext.Provider can pull out the value. The components in between 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 need to know anything about user. Much cleaner.
Step 3 — subscribe via useContext #
import { useContext } from 'react';
import { UserContext } from './UserContext';
function UserAvatar() {
const user = useContext(UserContext);
if (!user) return <p>Please sign 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 enclosing UserContext.Provider. It comes through in one shot, without going through the middle.
Supplying a value and a function together #
It is very common to make the Context value an object that holds state and its setter (or updater function) together. That way descendants can not only read the value but also change it.
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;Descendants pull out both theme (the current value) and toggleTheme (the changer). Thanks to this pattern, one Context exposes “the shared state and the way to operate on it” together.
Wrapping the Provider in its own component #
As Context usage grows, separating the Provider itself into its own component keeps things clean. It also gathers all 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 inside ThemeProvider, which also makes it easier to reuse elsewhere.
Wrapping once more with a custom hook #
Importing Context directly every time, as in useContext(ThemeContext), is also slightly tedious. It is a common pattern to wrap a frequently used Context in a custom hook for nicer usage.
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 consuming code gets noticeably shorter. The full discussion of custom hooks is in the next chapter, Chapter 13.
Don’t overuse Context #
Context is powerful, but it is not the tool for every situation. Keep the following in mind.
1. For simple prop passing, plain props are better #
For just one or two parent-child levels, props are far more explicit and easier to trace. Context shines when the depth is truly deep (3 ~ 4 levels or more) or when several branches of the tree need the same data.
2. When the Provider’s value changes, every subscriber underneath re-renders #
Context binds everything below it together, so if the value changes frequently, the re-renders go wide. Using Context for data that changes often (e.g., mouse coordinates) can cause performance problems.
You can mitigate this partially with the value-separation pattern — split frequently changing values and rarely changing values into two separate Contexts.
// Rarely changes — fine for every descendant to subscribe
<UserContext.Provider value={user}>
{/* Changes often — better to narrow the subscription scope */}
<CursorContext.Provider value={cursorPosition}>
<App />
</CursorContext.Provider>
</UserContext.Provider>3. Context is not a global state library #
Context is a “data pipeline”, not a sophisticated state-management tool by itself. For app-wide complex state (global user info + notifications + cart + settings, etc.), dedicated libraries like Zustand, Jotai, or Redux Toolkit fit better.
A short note on what each one is for:
- Zustand — lightest and least boilerplate. First pick for small-to-medium apps
- Jotai — state broken into atoms. Strong at partial subscription, friendly to performance
- Redux Toolkit — explicit action / reducer structure with devtools. Suits large teams and complex domains
Parts 1 ~ 2 of this book do not introduce external tools. What can be solved with Context plus lifting state up is the scope of Parts 1 ~ 2. The book does not cover introducing those external tools directly, but Appendix A (migrating old React) walks through the procedure for moving from Redux-only to RSC + Server Actions + a small client store.
Try it yourself #
Let’s build an example where a theme (light / dark) is managed via Context and 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>The color changes when the theme switches.</p>
</Card>
<Card>
<h2>Second card</h2>
<p>The two cards share the same theme.</p>
</Card>
</ThemeProvider>
);
}
export default App;Click the button and both cards change color simultaneously. Card and ThemeToggle don’t know each other exists, and the parent does not broker props between them — yet they share the same theme state. They now access the same data anywhere in the tree, without prop drilling.
Exercises #
- Add a third “high-contrast” theme to the example above, alongside dark and light. Instead of
toggleTheme, exposesetTheme(value)so a theme can be picked explicitly. Handle all three branches inCard’s styles. - Observe the re-render scope when a Context value changes. Drop
console.log('rendered')into a descendant component and change the value often. Confirm that every descendant under the Provider re-renders together. Then split frequently changing and rarely changing values into two separate Contexts and confirm that only a portion of descendants re-renders. - The authentication Context pattern. Build
AuthContextandAuthProvider, exposing three things:user/login(email, password)/logout(). On login, mocksetUser({ name: 'Cheolsu' }); on logout,setUser(null). Have two child components,LoginFormandUserBadge, subscribe to the same Context, and confirm that signing in on one side updates the other immediately. This is the foundation for Chapter 32 (auth and sessions).
In one line: Context is a channel that lets a descendant pull a value placed somewhere above it directly. Usage is three steps —
createContext→<Provider value={...}>→useContext. Bundling value and setter into an object is common, and it stays cleaner to extract the Provider logic into its own component and to wrap consumption in a custom hook. Simple prop passing is better as plain props, and Context is a poor fit for data that changes often. For complex global state, external tools like Zustand / Jotai / Redux Toolkit are the better fit.
Next chapter #
In this chapter we briefly used a small custom hook, useTheme. The next chapter, Chapter 13 Custom hooks, covers custom hooks in full — the most elegant tool for sharing logic between components. We also cover what a good hook’s interface looks like, and conversely, the criteria for “the cases where you should not extract a hook”.