跪拜 Guibai
← Back to the summary

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"?

useContext solves 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:

⚠️ 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:


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:


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

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: