At one point, I thought I had the perfect setup using useContext
in React. I wrapped my app in a provider, passed down shared state, and consumed it deep within multiple components. Everything looked clean—until I noticed that some components were re-rendering for no obvious reason. They weren’t even using the updated state directly. The performance started to dip, and debugging it led me down a rabbit hole of how context propagation really works.
Understanding the actual cause took some time, and the fix wasn’t just about code—it was about rethinking how I approached shared state with useContext
.
Context triggers all
The first thing I learned was that any time a context value changes, every single component that consumes that context will re-render, even if the part they’re using didn’t change. I had assumed React would somehow optimize this or only update components that accessed the updated portion of the value. It doesn’t. When the value
prop of the provider changes, React schedules re-renders for all consumers.
So if I had a context like this:
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
Even if only user
changed, any component using just logout
would still re-render. And once the app grew, this started affecting the performance of pages that had nothing to do with authentication changes.
Object identity problem
One of the silent culprits behind this was the identity of the object passed into the provider. Even if user
didn’t actually change, creating a new object with { user, login, logout }
on every render caused unnecessary updates. React saw a new reference each time and triggered re-renders downstream.
To fix this, I started using useMemo
to stabilize the context value:
const contextValue = React.useMemo(() => ({ user, login, logout }), [user, login, logout]);
This helped in some cases, especially where the context updates were frequent and subtle. But it didn’t fully eliminate the core issue.
Split the context
The bigger fix came when I started breaking my context into smaller, more focused contexts. Initially, I had one giant context for all app-wide state—user info, theme, modals, and more. The result was over-shared context leading to over-rendering. Splitting them into separate contexts—like ThemeContext
, AuthContext
, ModalContext
—was a game changer.
For example, ThemeContext
only handled the current theme and its toggle function. It no longer caused re-renders in places that only cared about login state. Each concern became isolated, and the re-renders dropped dramatically.
That same idea helped when I was working on custom hooks that only needed specific slices of shared logic without causing full updates across components.
Use selectors carefully
While React doesn’t natively support context selectors (like Redux does), I mimicked the pattern by moving parts of the context into custom hooks. If a component only needed the user
, I created a useUser
hook that subscribed to that piece separately. It wasn’t perfect, but it helped to separate the consumers logically and reduce over-fetching of unnecessary values.
const useUser = () => {
const { user } = useAuth();
return user;
};
This gave me finer control, and even though the re-renders weren’t entirely gone, they became much more predictable.
Keep providers shallow
Another mistake I made early on was nesting too many providers. I wrapped entire trees in a single AppProvider
that handled everything. But once I moved some providers closer to where the state was actually needed, the app started feeling faster.
Instead of wrapping the whole app in a ModalProvider
, I scoped it to where modals actually existed. This reduced the number of mounted consumers and minimized reactivity in unrelated parts of the tree.
Watch developer tools
I started relying more on the React Developer Tools Profiler to trace why components were re-rendering. Sometimes it wasn’t the context at all—it was a parent component or some memoized function that wasn’t truly memoized. But many times, it was exactly what I suspected: a new context value triggering updates down the line.
That process gave me insights similar to what I explored while optimizing React API call performance, where timing and data flow visibility were essential to tracking inefficiencies.
Don’t overuse context
Eventually, I realized not everything needs to be in context. I was overusing it—storing things like loading flags or input errors in global state when local useState
was enough. Keeping state local where possible helped reduce context churn and improved performance.
This also kept things simpler when building interactive components or temporary UIs like alerts or form validations, where shared state only complicated things.
Final takeaway
Context is powerful, but not magical. It doesn’t optimize your app out of the box, and it doesn’t know which values truly matter to a component. I learned the hard way that using useContext
for everything introduced invisible complexity and performance problems that were hard to trace. Fixing it wasn’t about abandoning context—it was about using it wisely, memoizing values, and designing smaller, more intentional boundaries for state.
Once I made that shift, components became faster, bugs became easier to spot, and the app just felt lighter. Even in complex apps where shared state is unavoidable, these small improvements had a big impact on how responsive everything felt.