Exploring React's useContext Hook
20. August, 2023 • 7 min read • Teach
Four levels down and still passing props
There's a moment in every React app where you add a prop to a component that doesn't use it, purely so it can hand it to a child that hands it to a child. That's the point at which I go looking for context.
useContext lets a component read a value from an ancestor without every component in between having to carry it. Themes, the current user, a locale, an open/closed flag for something global. It’s a transport mechanism, not a state manager, and keeping that distinction straight saves a lot of grief later.
The version with prop drilling
Take a navigation bar that tracks which item is active. Three files, and two of them are only involved because of the wiring:
// app.js
import React, { useState } from 'react';
import Navbar from 'components/nav';
const App = () => {
const [activeItem, setActiveItem] = useState('Home');
return (
<div className="app">
<Navbar activeItem={activeItem} setActiveItem={setActiveItem} />
{/* other stuff */}
</div>
);
};
export default App;// nav.js
import React from 'react';
import NavItem from 'components/nav-item';
const Navbar = ({ activeItem, setActiveItem }) => {
return (
<nav>
<NavItem
title="Home"
activeItem={activeItem}
setActiveItem={setActiveItem}
/>
<NavItem
title="About"
activeItem={activeItem}
setActiveItem={setActiveItem}
/>
</nav>
);
};
export default Navbar;// nav-item.js
import React from 'react';
const NavItem = ({ title, activeItem, setActiveItem }) => {
const handleClick = () => setActiveItem(title);
return (
<div className={title === activeItem ? 'active' : ''} onClick={handleClick}>
{title}
</div>
);
};
export default NavItem;Navbar does nothing with activeItem or setActiveItem. It is a courier. With two levels that’s tolerable, and by the time there’s a NavGroup and a NavDropdown in the middle it isn’t. Every new piece of navigation state means touching every file in the chain.
The same thing with context
Put the state in a provider and let the components that care read it directly:
// nav-context.js
import React, { createContext, useContext, useState } from 'react';
const NavContext = createContext(null);
const NavContextProvider = ({ children }) => {
const [activeItem, setActiveItem] = useState('Home');
return (
<NavContext.Provider value={{ activeItem, setActiveItem }}>
{children}
</NavContext.Provider>
);
};
const useNavContext = () => {
const context = useContext(NavContext);
if (context === null) {
throw new Error('useNavContext must be used inside a NavContextProvider');
}
return context;
};
export { NavContextProvider, useNavContext };// app.js
import React from 'react';
import Navbar from 'components/nav';
import { NavContextProvider } from 'components/nav-context';
const App = () => {
return (
<NavContextProvider>
<div className="app">
<Navbar />
{/* other stuff */}
</div>
</NavContextProvider>
);
};
export default App;// nav.js
import React from 'react';
import NavItem from 'components/nav-item';
const Navbar = () => {
return (
<nav>
<NavItem title="Home" />
<NavItem title="About" />
</nav>
);
};
export default Navbar;// nav-item.js
import React from 'react';
import { useNavContext } from 'components/nav-context';
const NavItem = ({ title }) => {
const { activeItem, setActiveItem } = useNavContext();
const handleClick = () => setActiveItem(title);
return (
<div className={title === activeItem ? 'active' : ''} onClick={handleClick}>
{title}
</div>
);
};
export default NavItem;Navbar is back to describing navigation instead of relaying props. Adding a third level of nesting costs nothing.
Always export a hook, never the context
The thing I’d insist on from that file is useNavContext. Exporting the raw context and calling useContext(NavContext) at every call site works, but it gives you no place to put the guard.
Without a guard, a component rendered outside the provider gets the default value back and fails somewhere else entirely, usually as Cannot destructure property 'activeItem' of undefined. With the guard you get a sentence telling you exactly what went wrong. That’s the difference between a ten-second fix and twenty minutes with the component tree open.
It’s also the seam where you’d later add a selector, a console.count while debugging, or a TypeScript type, without touching a single consumer.
The catch nobody mentions in the tutorial
Look at the provider again:
<NavContext.Provider value={{ activeItem, setActiveItem }}>That object literal is rebuilt on every single render of the provider. Context compares by reference, so every consumer re-renders every time, even the ones reading a field that didn’t change. React.memo on a consumer does not help, because the update comes through the context rather than through props.
For a navbar with four items, this genuinely does not matter and I would leave it alone. For a provider sitting near the root of a large tree, it does. The fix is to keep the value’s identity stable:
import React, { createContext, useMemo, useState } from 'react';
const NavContextProvider = ({ children }) => {
const [activeItem, setActiveItem] = useState('Home');
const value = useMemo(
() => ({ activeItem, setActiveItem }),
[activeItem, setActiveItem]
);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
};setActiveItem is stable across renders because React guarantees the identity of a useState setter, so in practice only activeItem moves the needle. I still list it in the dependency array to keep the lint rule quiet. If you put callbacks of your own in the value, wrap them in useCallback for the same reason, which I went into in Optimizing React Components.
Splitting state from setters
There’s a second move worth knowing, and it beats memoisation when the state changes often. Use two contexts:
const NavStateContext = createContext(null);
const NavDispatchContext = createContext(null);
const NavContextProvider = ({ children }) => {
const [activeItem, setActiveItem] = useState('Home');
return (
<NavDispatchContext.Provider value={setActiveItem}>
<NavStateContext.Provider value={activeItem}>
{children}
</NavStateContext.Provider>
</NavDispatchContext.Provider>
);
};A component that only dispatches, like a button that resets the navigation, subscribes to NavDispatchContext and never re-renders when activeItem changes. The setter’s identity is stable, so that context’s value never changes at all. Two providers is slightly more typing for a real reduction in work.
When I don’t reach for it
Context is not free and it is not a store. Some cases where I leave it alone:
- The tree is two levels deep. Passing a prop twice is clearer than a provider, an exported hook and an extra file.
- The data changes on every keystroke. Form state in context makes the whole subtree re-render as you type. Keep it local, lift it only as far as it needs to go.
- You want a store. If you need selectors, middleware, devtools or persistence, use something built for that. Context plus
useReducergets you a surprising distance, and then it stops. - Server data. Caching, revalidation and request deduplication are a different problem, and a query library solves it properly.
The failure mode I’ve watched happen is one giant AppContext holding everything, which re-renders the application whenever anything anywhere changes. That’s a worse position than the prop drilling it replaced, and it’s harder to unpick.
What I’d take away
Small, focused providers close to where they’re used. One hook per context, with a guard in it. And a useMemo on the value if the provider sits anywhere near the root.
That’s about it. useContext is one of the few React APIs that’s genuinely simple, and almost all the trouble people have with it comes from asking it to be a state manager.
‘Till next time!