One of the most common issues developers face is managing rerenders, especially when working with Context API. Today, I want to share a powerful technique that quite known but… 😄
The Problem with Traditional Context
Before diving into the solution, let’s understand the problem. When using React’s Context API in the traditional way, any component that consumes a context will rerender whenever any value within that context changes.
Consider this example:
// Traditional Context approach
const ThemeContext = React.createContext({
theme: "light",
setTheme: () => {},
});
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemeDisplay() {
const { theme } = useContext(ThemeContext);
console.log("ThemeDisplay rendering");
return <div>Current theme: {theme}</div>;
}
function ThemeToggle() {
const { setTheme } = useContext(ThemeContext);
console.log("ThemeToggle rendering");
return (
<button
onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}
>
Toggle Theme
</button>
);
}
The issue here is that both ThemeDisplay
and ThemeToggle
will rerender whenever the theme changes, even though ThemeToggle
only needs setTheme
and doesn’t actually use the current theme value in its rendering.
The Possible Solution: Context Splitting 💡
The context splitting pattern addresses this problem by separating our context into two distinct contexts:
- A data context that holds just the state (e.g.,
theme
) - A setter context that holds just the updater function (e.g.,
setTheme
)
Here’s how it looks in practice:
// Split Context approach
const ThemeContext = React.createContext("light");
const ThemeSetterContext = React.createContext(() => {});
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={theme}>
<ThemeSetterContext.Provider value={setTheme}>
{children}
</ThemeSetterContext.Provider>
</ThemeContext.Provider>
);
}
function ThemeDisplay() {
const theme = useContext(ThemeContext);
console.log("ThemeDisplay rendering");
return <div>Current theme: {theme}</div>;
}
function ThemeToggle() {
const setTheme = useContext(ThemeSetterContext);
console.log("ThemeToggle rendering");
return (
<button
onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}
>
Toggle Theme
</button>
);
}
With this pattern, when the theme changes:
ThemeDisplay
rerenders (as it should since it displays the theme)ThemeToggle
does NOT rerender because it only consumes the setter context, which never changes (the setter function reference remains stable)
Complete Example with Rerender Counting 🧪
Let’s create a more complete example that demonstrates the difference between the traditional and split context approaches. We’ll add rerender counters to visualize the performance impact:
import React, { useState, useContext, memo } from "react";
import ReactDOM from "react-dom/client";
/* ========== Traditional Context (Single) ========== */
const TraditionalThemeContext = React.createContext({
theme: "light",
setTheme: () => {},
});
const TraditionalThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
return (
<TraditionalThemeContext.Provider value={{ theme, setTheme }}>
{children}
</TraditionalThemeContext.Provider>
);
};
const TraditionalThemeDisplay = memo(() => {
const { theme } = useContext(TraditionalThemeContext);
console.log("🔁 TraditionalThemeDisplay re-rendered");
return (
<div>
Current theme: <strong>{theme}</strong>
</div>
);
});
const TraditionalThemeToggleButton = memo(() => {
const { setTheme } = useContext(TraditionalThemeContext);
console.log("🔁 TraditionalThemeToggleButton re-rendered");
return (
<button
onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}
>
Toggle Theme
</button>
);
});
/* ========== Optimized Context (Split) ========== */
const ThemeContext = React.createContext("light");
const SetThemeContext = React.createContext(() => {});
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={theme}>
<SetThemeContext.Provider value={setTheme}>
{children}
</SetThemeContext.Provider>
</ThemeContext.Provider>
);
};
const OptimizedThemeDisplay = memo(() => {
const theme = useContext(ThemeContext);
console.log("✅ OptimizedThemeDisplay re-rendered");
return (
<div>
Current theme: <strong>{theme}</strong>
</div>
);
});
const OptimizedThemeToggleButton = memo(() => {
const setTheme = useContext(SetThemeContext);
console.log("✅ OptimizedThemeToggleButton re-rendered");
return (
<button
onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}
>
Toggle Theme
</button>
);
});
/* ========== App ========== */
export const App = () => {
return (
<div style={{ fontFamily: "sans-serif", padding: 20 }}>
<h1>🎨 Theme Context Comparison</h1>
<div style={{ marginBottom: 40, padding: 10, border: "1px solid gray" }}>
<h2>🧪 Traditional Context (Single)</h2>
<TraditionalThemeProvider>
<TraditionalThemeDisplay />
<TraditionalThemeToggleButton />
</TraditionalThemeProvider>
</div>
<div style={{ marginBottom: 40, padding: 10, border: "1px solid gray" }}>
<h2>⚡ Optimized Context (Split)</h2>
<ThemeProvider>
<OptimizedThemeDisplay />
<OptimizedThemeToggleButton />
</ThemeProvider>
</div>
</div>
);
};
Initial render:

Now lets see what happens when we click toggle buttons

What to Look for in Console
Each toggle will log:
Traditional Context:
🔁 TraditionalThemeDisplay re-rendered
🔁 TraditionalThemeToggleButton re-rendered ✅ ← re-renders unnecessarily
Optimized Context:
✅ OptimizedThemeDisplay re-rendered
✅ OptimizedThemeToggleButton re-rendered ❌ ← does NOT re-render
Optimizing with useMemo
We can optimize the traditional approach somewhat by using useMemo
to prevent the context value object from being recreated on every render:
function OptimizedTraditionalProvider({ children }) {
const [theme, setTheme] = useState("light");
// Memoize the value object to prevent unnecessary context changes
const value = useMemo(
() => ({
theme,
setTheme,
}),
[theme]
);
return (
<TraditionalThemeContext.Provider value={value}>
{children}
</TraditionalThemeContext.Provider>
);
}
This helps, but still has the fundamental issue that components consuming only setTheme
will rerender when theme
changes. The split context approach solves this problem more elegantly.
When to Use Context Splitting
Context splitting is particularly valuable when:
- You have many components that only need to update state but don’t need to read it
- You have expensive components that should only rerender when absolutely necessary
- Your app has deep component trees where performance optimization matters
Potential Downsides
While context splitting is powerful, it does come with some trade-offs:
- Increased Complexity — Managing two contexts instead of one adds some boilerplate
- Provider Nesting — You end up with more nested providers in your component tree
- Mental Overhead — Developers need to choose the right context for each use case
Custom Hooks for Clean API
To make this pattern more developer-friendly, you can create custom hooks:
function useTheme() {
return useContext(ThemeContext);
}
function useSetTheme() {
return useContext(ThemeSetterContext);
}
// Usage
function MyComponent() {
const theme = useTheme();
const setTheme = useSetTheme();
// ...
}
Measuring the Performance Impact
When you run the demo code provided above, you’ll see a clear difference in render counts:
- With the traditional context, both the reader and toggler components rerender when the theme changes
- With the split context, only the reader rerenders while the toggler’s render count stays the same
This performance difference might seem small in a simple example, but in a real application with dozens or hundreds of components consuming context, the impact can be substantial.
Conclusion 🚀
Context splitting is a powerful technique for optimizing React applications that use the Context API. By separating your state and setter functions into different contexts, you can ensure components only rerender when the specific data they consume changes.
While this technique adds some complexity to your codebase, the performance benefits can be visible in larger applications.