Skip to content

React Tutorial Advanced React Concepts

The React Context API: Providers, Custom Hooks and Pitfalls

Lesson 115 of 156 3 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

You learned the basics of Context in useContext. This lesson shows the structure real apps use: a provider component that owns the state, a custom hook to read it, and habits that avoid performance problems.

The pattern

  1. Create the context in its own file.
  2. Write a Provider component that holds the state and provides it.
  3. Write a custom hook that reads the context and complains if used outside the provider.
App.jsx
JSX
const CartContext = React.createContext(null);

function CartProvider({ children }) {
  const [items, setItems] = React.useState([]);

  const add = React.useCallback((item) => setItems((list) => [...list, item]), []);
  const clear = React.useCallback(() => setItems([]), []);
  const total = items.reduce((sum, i) => sum + i.price, 0);

  // Memoize the value so consumers do not re-render when the provider re-renders for other reasons
  const value = React.useMemo(() => ({ items, total, add, clear }), [items, total, add, clear]);

  return <CartContext value={value}>{children}</CartContext>;
}

// The only way components read the cart
function useCart() {
  const context = React.useContext(CartContext);
  if (context === null) {
    throw new Error("useCart must be used inside a <CartProvider>");
  }
  return context;
}

function ProductButtons() {
  const { add } = useCart();
  return (
    <div>
      <button onClick={() => add({ name: "Pen", price: 2.5 })}>Add Pen</button>{" "}
      <button onClick={() => add({ name: "Book", price: 15 })}>Add Book</button>
    </div>
  );
}

function CartSummary() {
  const { items, total, clear } = useCart();
  if (items.length === 0) return <p>Cart is empty</p>;
  return (
    <div>
      <p>{items.length} items, ${total.toFixed(2)}</p>
      <button onClick={clear}>Clear cart</button>
    </div>
  );
}

function App() {
  return (
    <CartProvider>
      <ProductButtons />
      <CartSummary />
    </CartProvider>
  );
}

Why this shape works:

  • The state and its update rules live in one place, the provider, so components just call add() or clear().
  • useCart hides the context object. Components do not import CartContext; they call useCart(). You can change the internals later without touching them.
  • The guard turns a confusing undefined is not iterable error into a clear message about the real mistake.
  • App just wraps the part of the tree that needs the cart.

Why useMemo on the value?

When CartProvider re-renders (for any reason, such as its parent re-rendering), a new value object is created. Context compares the value with Object.is, so a new object means every consumer re-renders, even if nothing inside changed. useMemo returns the same object until its dependencies change. In a small app this does not matter; in a large one it can. The React Compiler can add this memoization for you (React Compiler).

Split contexts by what changes together

One giant context with everything makes every consumer re-render for every change. Split by update frequency or by purpose:

App.jsx
JSX
<ThemeProvider>        // rarely changes
  <AuthProvider>       // changes at login and logout
    <CartProvider>     // changes often
      <App />
    </CartProvider>
  </AuthProvider>
</ThemeProvider>

Another useful split is state vs dispatch: components that only trigger actions (add) need not re-render when the data changes. Put the stable functions in a second context.

App.jsx
JSX
const CountContext = React.createContext(0);
const ActionsContext = React.createContext(null);

// memo: skip re-rendering when the parent re-renders with the same (no) props
const CountButton = React.memo(function CountButton() {
  const actions = React.useContext(ActionsContext); // stable: never changes
  const renders = React.useRef(0);
  renders.current++;
  return (
    <div>
      <button onClick={actions.inc}>Count up</button>
      <p>Renders of button: {renders.current}</p>
    </div>
  );
});

function CountText() {
  const count = React.useContext(CountContext);
  return <p>Count: {count}</p>;
}

function App() {
  const [count, setCount] = React.useState(0);
  const actions = React.useMemo(() => ({ inc: () => setCount((c) => c + 1) }), []);

  return (
    <ActionsContext value={actions}>
      <CountContext value={count}>
        <CountButton />
        <CountText />
      </CountContext>
    </ActionsContext>
  );
}

CountText re-renders on every click because it reads the changing CountContext. CountButton reads only the stable ActionsContext and is wrapped in memo, so it does not re-render when the count changes: its own render counter stays at 1. Both parts are needed: splitting the contexts stops context-caused re-renders, and memo stops re-renders that come from the parent re-rendering (Performance Optimization).

Pitfalls

  • Using context for fast-changing values (mouse position, form text). Every consumer re-renders each time.
  • A provider too high in the tree, causing wide re-renders. Place it as low as practical.
  • Forgetting the provider. The default value silently applies. The guard in useCart prevents it.
  • Context as a global store. It does not give selectors or middleware. For large shared client state, a store library is a better fit.
  • Creating the context inside a component. Always define createContext at module level.

With a reducer

For state with many update types, combine context with useReducer (Reducers and useReducer): the provider holds [state, dispatch], and consumers dispatch actions. This pattern is a lightweight alternative to a store library.

Key points

  • Structure Context as a Provider component plus a custom hook with a guard.
  • Memoize the provided value and split contexts by what changes together.
  • Keep context for broadly needed, slowly changing data.