Stop Calling useContext Directly in Your Components
Are You Really Using useContext Correctly? — The Right Way to Wrap Context in a Custom Hook
Opening: Two Approaches, Clear Winner
You've learned useContext and finally don't need to pass props layer by layer. So in every component that reads Context, you write:
// Component A
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
function Page() {
const theme = useContext(ThemeContext);
return <div className={theme}>Current theme: {theme}</div>;
}
// Component B
function Child() {
const theme = useContext(ThemeContext);
return <button className={theme}>Button</button>;
}
// Component C
function Footer() {
const theme = useContext(ThemeContext);
return <footer className={theme}>Footer</footer>;
}
It works. Theme switches, and all three components update.
But two weeks later you notice: every component file has two imports at the top — useContext and ThemeContext. Five consuming components mean 10 repeated imports. Want to add "log when theme switches"? You have to edit five files one by one.
Then you see another approach:
// hooks/useTheme.js — only one place calls useContext
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
export function useTheme() {
return useContext(ThemeContext);
}
Inside each component, it becomes:
import { useTheme } from '../hooks/useTheme';
function Page() {
const theme = useTheme(); // Clean call, no need to know which Context is behind it
return <div className={theme}>Current theme: {theme}</div>;
}
This article answers one question: both approaches work, but what exactly makes the second one better? And — why the React community considers it "the best entry-level scenario for custom Hooks."
Core Concept: Why "Custom Hooks"?
useContextsolves the "cross-level" problem but not the "maintainability" problem. A custom Hook adds your own layer of encapsulation to Context consumption — the component only knows "I'm using a theme," not "the theme comes from which Context."
A real-life analogy:
Direct useContext = Making dumplings from scratch every time — kneading dough, rolling wrappers, mixing filling. It works, but it's tiring.
Custom Hook = Your mom already made the dumplings and put them in the freezer. You just need to boil them.
The key difference: when a component calls useTheme(), it doesn't care whether the theme data comes from Context, localStorage, or a server WebSocket push. It only cares about "give me the current theme." That's the value of encapsulation.
Three Stages of Evolution: Component Communication's Three Phases
These are three ways the same code was written in my project, from primitive to elegant.
graph TD
A["Stage 1: props passing layer by layer"] --> B["Stage 2: useContext direct consumption"]
B --> C["Stage 3: Custom Hook wrapping Context"]
A -.-> D["❌ Deeper hierarchy, more pain"]
B -.-> E["⚠️ Works but scattered everywhere"]
C -.-> F["✅ Test-friendly, easy to change"]
Stage 1: props drilling (primitive era)
Without Context, ancestor component data had to be passed all the way down via props to great-grandchildren:
// App → Page → Child, every layer has to receive and pass
function App() {
const [theme, setTheme] = useState('light');
return <Page theme={theme} />; // pass to Page
}
function Page({ theme }) {
return <Child theme={theme} />; // Page doesn't use it, but must receive and pass down
}
function Child({ theme }) {
return <button className={theme}>Button</button>; // finally used
}
The Page component doesn't use theme itself but is forced to declare { theme } to receive and pass it to Child. This is typical props porter — the deeper the component hierarchy, the more tedious the porter work.
Stage 2: useContext direct consumption (modern but not good enough)
After introducing Context, intermediate layers are freed:
// ThemeContext.jsx — create a "data channel"
import { createContext } from 'react';
export const ThemeContext = createContext('light');
// App.jsx — Provider: put data into Context
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Page />
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Switch theme
</button>
</ThemeContext.Provider>
);
}
Page and Child get data directly from Context:
// Page.jsx — intermediate component, no longer needs to receive props
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
function Page() {
const theme = useContext(ThemeContext);
return (
<>
<div>Current theme: {theme}</div>
<Child />
</>
);
}
// Child.jsx — deep component, directly consumes Context
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
function Child() {
const theme = useContext(ThemeContext);
return <button className={theme}>Button</button>;
}
What's the problem? Page and Child each import useContext and ThemeContext in every file. If you need to make any of the following changes later, you have to edit all consuming components:
- Add a fallback for theme (e.g., use localStorage when no Provider wraps Context)
- Log or report analytics on theme switch
- Switch theme data source from Context to a global state library (Zustand/Jotai)
⚠️ Core pain point:
useContext(ThemeContext)exposes two implementation details to the component — "I'm using React Context" and "the data source is called ThemeContext." The component doesn't need to know these.
Stage 3: Custom Hook wrapping Context (the correct approach)
Extract the useContext call into a custom Hook, exposing only a clean interface to the outside.
// hooks/useTheme.js — 🔑 the only useContext call site
import { useContext } from 'react';
import { ThemeContext } from '../ThemeContext';
export function useTheme() {
return useContext(ThemeContext);
}
This file is tiny, but what it does is very important: it consolidates the knowledge of "how to get theme data" from 5 components into 1 file.
All components now only need:
// Page.jsx — component only knows useTheme, unaware ThemeContext exists
import { useTheme } from '../hooks/useTheme';
function Page() {
const theme = useTheme();
return (
<>
<div>Current theme: {theme}</div>
<Child />
</>
);
}
The change is clear at a glance:
| Stage 2 (direct useContext) | Stage 3 (custom Hook) | |
|---|---|---|
| Import lines per component | 2 lines (useContext + ThemeContext) | 1 line (useTheme) |
| Does component know data source? | Yes — depends on ThemeContext | No — only depends on useTheme |
| How many files to change when switching data source? | All consuming components | Only useTheme.js |
| Can add intermediate logic? | Each component adds its own | Add once in useTheme |
| Test friendliness | Need to mock Context | Only need to mock useTheme |
Real-world: Requirement Changes Reveal the Truth
Suppose the product team gives you three new requirements. Let's see the scope of changes for both approaches:
Requirement 1: When Context has no Provider wrapper, fall back to theme in localStorage
Stage 2 approach (nightmare) — every component must change:
// ❌ 5 component files, each must add this logic
function Page() {
const ctxTheme = useContext(ThemeContext);
const theme = ctxTheme || localStorage.getItem('theme') || 'light'; // repeated in every file
// ...
}
Stage 3 approach (satisfying) — only change 1 file:
// ✅ hooks/useTheme.js — only change here, all components automatically take effect
export function useTheme() {
const ctxTheme = useContext(ThemeContext);
return ctxTheme || localStorage.getItem('theme') || 'light';
}
Requirement 2: Report analytics on theme switch
In Stage 3, just add a useEffect or callback inside useTheme, and all components are unaware.
Requirement 3: Unit testing
// Stage 2: testing Page component must wrap with ThemeContext.Provider
render(
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
// Stage 3: directly mock the single useTheme function
jest.mock('../hooks/useTheme', () => ({
useTheme: () => 'dark'
}));
The number of files changed goes from N to 1. That's the power of encapsulation.
Not Just Context — The True Role of Custom Hooks
There's another custom Hook in the project:
// hooks/useMouse.js — track mouse position
import { useState, useEffect } from 'react';
export function useMouse() {
const [position, setPosition] = useState({ x: null, y: null });
useEffect(() => {
// 🔑 Event handler defined inside useEffect — correctly references setter
// ⚠️ Common mistake: if defined outside, reference recreated each render, may cause stale closure
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener('mousemove', handleMouseMove);
return () => {
// 🔑 Cleanup function: remove event listener on unmount to prevent memory leak
// Without cleanup, even after unmount, every mouse move still triggers setState
// → React prints warning in console: "Can't perform a React state update on an unmounted component"
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return position;
}
Using it in a component takes just one line:
// App.jsx — consumer of the useMouse custom Hook
import { useMouse } from './hooks/useMouse';
function App() {
const { x, y } = useMouse();
return (
<div>
{x && y ? `Mouse X: ${x}, Mouse Y: ${y}` : 'Mouse not moved'}
</div>
);
}
The App component doesn't need to know that useMouse internally uses useState, useEffect, addEventListener. It only cares about "give me mouse coordinates."
The essence of custom Hooks: encapsulate React's reactive logic (state + effect) into reusable functions. The difference from ordinary utility functions is — custom Hooks can internally use
useState,useEffect,useContext, and all other React Hooks.
The best entry point for custom Hooks is wrapping useContext. The reasons are simple:
- No new concepts — you're already using useContext
- Minimal code change — just move one line
useContext(XXX)into a separate file - Immediate payoff — fewer component imports, cleaner code
Component Communication Panorama
By now, you've mastered all means of React component communication. A summary diagram:
graph TD
A["Component Communication"] --> B["Parent-Child Relationship"]
A --> C["Grandparent-Grandchild / Deep Relationship"]
A --> D["Stranger Relationship"]
B --> B1["props pass data"]
B --> B2["Callback functions (child→parent)"]
C --> C1["useContext + Custom Hook"]
C --> C2["State management library (Redux/Zustand)"]
D --> D1["State management library"]
D --> D2["URL parameters"]
D --> D3["localStorage / Event bus"]
Selection principles:
- Only 1-2 layers deep → props, simple and direct
- 3+ layers deep but scope is controllable → useContext + Custom Hook, lightweight and efficient
- Global sharing, complex update logic → State management library, professional solution
Conclusion: Back to the Opening Question
The opening asked: both approaches work, so what makes wrapping into a custom Hook better?
It's better because "when you need to change things in the future, you only need to change one file, not N files."
Remember in one sentence: Replace all your useContext(XXXContext) with useXXX() — Context is responsible for "storing data," custom Hook is responsible for "fetching data." Components only care about "using data."
Next time you write Context, just do this one thing
- After creating Context, immediately write a
use[Name]custom Hook - All components only call the custom Hook, never call
useContextdirectly - If any component has
useContext(ThemeContext), move it intouseTheme
This habit is so simple it's impossible to fail, yet it elevates your code from "works" to "maintainable."
Open question: In your projects, do you use useContext directly or wrap it in a custom Hook? Have you ever had a painful experience of changing N files because you didn't encapsulate? Or what's your take on "over-encapsulation" — for example, if Context is consumed in only one place, is it still worth wrapping? Discuss in the comments.
Other Articles in This Series
This demo project belongs to the React Hooks: From Pitfalls to Mastery series:
- Three Counterintuitive Behaviors of useState —
setCount(count+1)three times only increments once? - The Essence of useRef — Not just for binding DOM
- React State Lifting — TodoList Hands-on — Which component should own the state
- ← This article: useContext + Custom Hook — You are here