Context API Performance: Why You Should Split Your State
1. "Redux Is Too Complex, I'll Just Use Context!"
Early in the project, I hated the Redux boilerplate, so I decided to use React's built-in Context API.
I created a single AppStateContext and dumped everything in it: user info, theme, modal state, notification list, etc.
const AppStateContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [modal, setModal] = useState(false);
// 😱 Worst Code: Bundling all state into one object
const value = { user, setUser, theme, setTheme, modal, setModal };
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
}
It was convenient. Just useContext(AppStateContext) and I could grab data anywhere.
But as the app grew, problems exploded.
"I switched to Dark Mode (setTheme), why did my Signup Form clear?"
"I opened a modal, why did the entire page flash?"
2. What Did I Misunderstand? (The Secret of Subscription)
I mistakenly thought Context "subscribes only to necessary data."
I thought const { theme } = useContext(AppState) meant it would only re-render when theme changes.
But React Context's mechanism is brutally simple. "If the Provider's value changes, force re-render ALL components subscribing to it."
Because I was creating a new value object ({...}) every time, even if only theme changed, the value object's reference changed.
Then LoginForm, Header, Sidebar—everyone subscribing to value—thought "Oh? Data changed?" and re-rendered.
3. The 'Aha!' Moment (Radio Station Analogy)
I understood this when I compared it to a "Village PA System."
- Redux/Zustand: It's a smartphone notification. If I subscribe to "Sports News," I only get notified for sports. (Selector support)
- Context API: It's a village loudspeaker. If the chief turns on the mic to say, "Correction on the previous announcement!", everyone in the village (subscribing components) has to stop and listen. "Excuse me, I only care about sports?" -> Doesn't matter. Loudspeakers don't have channel separation.
Putting everything in one Context is like "Broadcasting every trivial gossip via one loudspeaker 24/7."
4. The Solution: Split It Again and Again (Context Splitting)
The core usage of Context API optimization is "Separation of Concerns." Unrelated states must reside in different Contexts.
Step 1: Split State and Dispatch
The most common pattern is separating "Value" and "Function (Setter)."
Values change often, but functions (setUser) don't change throughout the component lifecycle.
export const UserStateContext = createContext(null);
export const UserDispatchContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
// Only re-renders here if value changes
<UserStateContext.Provider value={user}>
{/* Functions don't change, so this is safe */}
<UserDispatchContext.Provider value={setUser}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
Now, child components that only need setUser (like a login button) subscribe only to UserDispatchContext.
So even if user state changes (login success), the button component doesn't re-render!
Step 2: Split by Domain
ThemeContext, ModalContext, UserContext...
Even if it's annoying, split the files.
ThemeContext: Light/Dark mode (Global impact)FormContext: State used only within a specific page (Local)
"Doesn't too many Contexts cause Provider Hell?" Yes, it looks ugly. But for performance, this is the right way.
5. Advanced: Custom Hook and Selector Pattern
Instead of using Context directly, it's better to encapsulate internals with Custom Hooks.
// Hooks/useUser.ts
export function useUser() {
const state = useContext(UserStateContext);
if (!state) throw new Error('Cannot find UserProvider');
return state;
}
export function useUserDispatch() {
const dispatch = useContext(UserDispatchContext);
if (!dispatch) throw new Error('Cannot find UserProvider');
return dispatch;
}
This way, components don't need to know useContext.
Also, if you later switch from Context to Redux or Recoil, you don't need to modify component code. Just change the hook internals.
6. Advanced: Memoization (React.memo)
Even with split Contexts, if the Provider's parent re-renders, child components might also re-render.
Wrap components with React.memo.
"Re-renders caused by Context updates" pierce through React.memo,
but "Re-renders caused by Parent updates" are blocked by React.memo.
7. Deep Dive: Difference from Redux/Zustand
"So when should I use Context vs. Libraries?"
-
Context API:
- Low Frequency Update: Theme, Language, User Info. Values that don't change often.
- Pros: No install needed. Built-in.
- Cons: Hard to optimize rendering. No Selector feature.
-
Zustand / Recoil / Redux:
- High Frequency Update: Mouse coordinates, Text input, Animation, Complex dashboard data.
- Pros: Supports Selectors (
useStore(state => state.bears)). Re-renders only when the data I want changes.
Putting input forms or realtime chart data into Context is suicide. Use Zustand or Recoil for that.
8. Case Study: Realtime Chat App Disaster
This problem became dramatic when I built a Chat App.
I put messages (chat list) and typingUsers (people typing) in the same ChatContext.
Every time someone typed, typingUsers changed (5 times a second).
Every time, the entire message list (thousands of items) subscribing to ChatContext re-rendered.
Result: Horrible lag every time I typed.
Solution:
- Adopted Zustand: Split components with
useChatStore(state => state.typingUsers). - Split Context: Moved typing state to a separate
TypingContext.
Typing lag disappeared completely. Lesson: Never mix "High Frequency" and "Low Frequency" values.
9. Pro Tip: React DevTools Profiler
"I want to SEE where re-renders happen." Open the Profiler tab in Chrome extension React DevTools.
- Click Record.
- Perform action (Toggle Dark Mode).
- Stop Record.
It tells you exactly which component rendered and "Why did this render?" (Reason: Hook 1 changed). The best tool to catch the culprit.
10. Glossary
- Prop Drilling: Passing data through multiple layers of components (Parent -> Child -> Grandchild) just to get it where it's needed. Context solves this.
- Memoization: Caching a result so it doesn't have to be recomputed.
useMemocaches values,useCallbackcaches functions. - Selector: A function that selects a specific piece of state from a larger store. Redux and Zustand use this to optimize renders.
- Provider Hell: A situation where your component tree is deeply nested with multiple Context Providers.
11. FAQ: Common Questions
Q: Can I use multiple Contexts in one component?
A: Yes. A component can consume UserContext AND ThemeContext. It will re-render if either changes.
Q: Should I wrap everything in useMemo?
A: Inside the Provider? Yes. The value prop should almost always be memoized. Inside the Consumer? Only if the computation is expensive.
Q: Is Context slow?
A: No, Context itself is fast. What's slow is Re-rendering the entire subtree. If you put context at the very top (index.js) and update it often, you force React to diff the entire DOM tree repeatedly.
Q: What about useReducer?
A: useReducer + Context is a powerful combo that mimics Redux. You pass the dispatch function down the context. This works great for complex state logic, but it still suffers from the same re-rendering optimization issues as plain useState if you don't split the contexts.
12. Summary: Don't Treat Context Like a Buffet
- Don't dump everything in Context. It tastes bad if mixed like a buffet plate.
- Every update wakes up ALL subscribers.
- Split State (Value) and Dispatch (Function).
- Split Contexts by Domain (Feature).
- Use Zustand/Recoil for frequent updates.
Context is for Dependency Injection (DI), not High-Performance State Management.