Context Splitting in React: A Technique to Prevent Unnecessary Rerenders

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:

  1. data context that holds just the state (e.g., theme)
  2. 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:

  1. You have many components that only need to update state but don’t need to read it
  2. You have expensive components that should only rerender when absolutely necessary
  3. Your app has deep component trees where performance optimization matters

Potential Downsides

While context splitting is powerful, it does come with some trade-offs:

  1. Increased Complexity β€” Managing two contexts instead of one adds some boilerplate
  2. Provider Nesting β€” You end up with more nested providers in your component tree
  3. 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:

  1. With the traditional context, both the reader and toggler components rerender when the theme changes
  2. 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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *