Skip to content

Context Re-render Problem and Splitting

advanced12 min read

The Hidden Bomb in Your Context

You create a context with a few values. Everything works. Then the app grows, more components consume it, and suddenly every keystroke re-renders 40 components. The culprit: a single context that mixes frequently-changing and rarely-changing values.

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);
  const [searchQuery, setSearchQuery] = useState('');  // Changes on every keystroke

  return (
    <AppContext.Provider value={{
      user, setUser,
      theme, setTheme,
      notifications, setNotifications,
      searchQuery, setSearchQuery,  // This bombs every consumer
    }}>
      {children}
    </AppContext.Provider>
  );
}

Every keystroke in the search input calls setSearchQuery, which re-renders AppProvider, which creates a new value object, which triggers re-renders in EVERY component that calls useContext(AppContext) — even those that only read theme or user.

The Mental Model

Mental Model

Think of a React context as a radio station. Every component that calls useContext is tuned to that station. When the station broadcasts a signal (value changes), every radio receiver activates — even if the message isn't relevant to them.

If one station broadcasts weather, sports, and breaking news, every listener hears everything. A weather app re-renders when sports scores update. A sports widget re-renders when the weather changes.

Context splitting is creating separate stations: one for weather, one for sports, one for breaking news. Each listener tunes to only the station they need.

Why Context Triggers All Consumers

React's context implementation is simple: when the Provider's value prop changes (via Object.is), React walks the fiber tree to find all consumers and schedules re-renders for each one.

// Simplified from React source
function propagateContextChange(workInProgress, context, renderLanes) {
  let fiber = workInProgress.child;

  while (fiber !== null) {
    // Check if this fiber reads the context
    const dependency = fiber.dependencies;
    if (dependency !== null) {
      if (dependency.contexts.includes(context)) {
        // Force a re-render on this consumer
        scheduleUpdateOnFiber(fiber, renderLanes);
      }
    }
    // Continue tree traversal...
    fiber = getNextFiber(fiber);
  }
}

There's no selectivity. React doesn't check which part of the context value a consumer reads. It doesn't do fine-grained subscription. If the context reference changed, ALL consumers render.

Execution Trace
Trigger
setSearchQuery('a')
Provider component re-renders
New value
value={{ ...state, searchQuery: 'a' }}
New object reference (Object.is fails)
Propagate
Walk fiber tree
Find every useContext(AppContext) consumer
Consumer 1
SearchBar re-renders
Uses searchQuery — this re-render is necessary
Consumer 2
ThemeToggle re-renders
Only uses theme — this re-render is WASTED
Consumer 3
UserAvatar re-renders
Only uses user — this re-render is WASTED
Consumer 4
NotifBadge re-renders
Only uses notifications — this re-render is WASTED

Solution 1: Split Contexts by Update Frequency

Separate fast-changing values from slow-changing ones:

// Split into multiple contexts by change frequency
const UserContext = createContext(null);     // Changes: on login/logout
const ThemeContext = createContext('light');  // Changes: on toggle
const SearchContext = createContext('');      // Changes: on every keystroke
const NotificationContext = createContext([]); // Changes: on new notification

function AppProviders({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [search, setSearch] = useState('');
  const [notifications, setNotifications] = useState([]);

  return (
    <UserContext.Provider value={useMemo(() => ({ user, setUser }), [user])}>
      <ThemeContext.Provider value={useMemo(() => ({ theme, setTheme }), [theme])}>
        <NotificationContext.Provider value={useMemo(() => ({ notifications, setNotifications }), [notifications])}>
          <SearchContext.Provider value={useMemo(() => ({ search, setSearch }), [search])}>
            {children}
          </SearchContext.Provider>
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Now keystroke updates only re-render components that consume SearchContext. UserAvatar (reads UserContext) and ThemeToggle (reads ThemeContext) are unaffected.

Solution 2: Split State from Dispatch

This one's clever, and you'll want to remember it. Separate the read-only state from the update functions:

const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);

function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, []);

  return (
    <TodoStateContext.Provider value={todos}>
      <TodoDispatchContext.Provider value={dispatch}>
        {children}
      </TodoDispatchContext.Provider>
    </TodoStateContext.Provider>
  );
}

// Components that only ADD todos don't re-render when the list changes
function AddTodoButton() {
  const dispatch = useContext(TodoDispatchContext); // dispatch is stable!
  return <button onClick={() => dispatch({ type: 'add', text: 'New' })}>Add</button>;
}

// Components that DISPLAY todos re-render when the list changes
function TodoList() {
  const todos = useContext(TodoStateContext);
  return todos.map(t => <TodoItem key={t.id} todo={t} />);
}

dispatch from useReducer is referentially stable — it never changes. So TodoDispatchContext's value never changes, and AddTodoButton never re-renders from context changes.

Solution 3: Memoize the Provider Value

At minimum, always memoize the context value object:

// BAD: new object on every render
function Provider({ children }) {
  const [count, setCount] = useState(0);
  return (
    <MyContext.Provider value={{ count, setCount }}>
      {children}
    </MyContext.Provider>
  );
}

// GOOD: memoized object, only changes when count changes
function Provider({ children }) {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);
  return (
    <MyContext.Provider value={value}>
      {children}
    </MyContext.Provider>
  );
}
Common Trap

Memoizing the value prevents unnecessary context propagation when the Provider's PARENT re-renders but the context state hasn't changed. Without memoization, every parent re-render creates a new value object, triggering all consumers even though nothing in the context state changed.

But memoization does NOT help when the context state actually changes. If count changes, all consumers still re-render — whether they use count or not.

Solution 4: Component-Level Memoization Barrier

Wrap consumers in memo to prevent cascading:

const MemoizedHeader = memo(function Header() {
  const { theme } = useContext(ThemeContext);
  return <header className={theme}>...</header>;
});

This only helps against parent re-renders, not context re-renders. If ThemeContext changes, MemoizedHeader still re-renders because context bypasses memo. Memo only prevents re-renders from parent component re-renders.

Production Scenario: The 200ms Keystroke

This is a real story I've seen play out at multiple companies. A team uses a single "global" context for app state:

function GlobalProvider({ children }) {
  const [state, dispatch] = useReducer(globalReducer, initialState);
  return (
    <GlobalContext.Provider value={{ state, dispatch }}>
      {children}
    </GlobalContext.Provider>
  );
}

// 40+ components consume this context
function SearchInput() {
  const { state, dispatch } = useContext(GlobalContext);
  return (
    <input
      value={state.searchQuery}
      onChange={e => dispatch({ type: 'SET_SEARCH', payload: e.target.value })}
    />
  );
}

Every keystroke dispatches SET_SEARCH, which creates a new state object, which creates a new context value, which re-renders all 40+ consumers. Each consumer is a dashboard widget with charts and tables. Total re-render time: 200ms per keystroke. The UI feels frozen.

The fix:

// 1. Move search to its own context
const SearchContext = createContext({ query: '', setQuery: () => {} });

// 2. Keep dashboard data in a separate context
const DashboardContext = createContext(null);

// 3. Search input only consumes SearchContext
function SearchInput() {
  const { query, setQuery } = useContext(SearchContext);
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// Dashboard widgets only consume DashboardContext — unaffected by typing

Keystroke latency drops from 200ms to 2ms.

Why React doesn't support context selectors

Libraries like Redux have useSelector(state => state.user.name) — the component only re-renders when state.user.name changes. React's useContext doesn't support this.

The React team considered and rejected built-in selectors for context. The reason: selectors require either:

  1. Object identity tracking — tracking which properties a component accesses (complex, fragile)
  2. Selector functionsuseContext(ctx, state => state.name) would need memoization of selectors and deep comparison of results

Both add complexity to React's core. The recommended solution is split contexts (simpler, more explicit) or external state management libraries (zustand, jotai) that implement selectors.

The use() hook in React 19 reads context but doesn't add selector support either. Context remains an all-or-nothing subscription.

Common Mistakes

What developers doWhat they should do
Putting all app state into a single context
A single context means every state change re-renders every consumer. Split contexts limit re-renders to relevant consumers
Split contexts by domain and update frequency. Separate fast-changing from slow-changing values
Not memoizing the context Provider value
Without memoization, parent re-renders create new value references, triggering all consumers even when context state hasn't changed
Always useMemo the value object passed to Provider
Using React.memo to prevent context-triggered re-renders
Context propagation is a separate mechanism from props. React finds consumers via fiber tree walking, regardless of memo boundaries
memo prevents parent-triggered re-renders, not context-triggered ones. Context bypasses memo
Passing inline objects as context value: value={{ user, theme }}
Inline objects create new references every render. Even if user and theme haven't changed, the new object reference triggers all consumers
Memoize: value=`{useMemo(() => ({ user, theme }`), [user, theme])}

Challenge

Challenge: Fix the context performance issue

// This dashboard has a performance problem.
// Typing in the search box causes all widgets to re-render.
// Fix it by restructuring the context.

const DashboardContext = createContext(null);

function DashboardProvider({ children }) {
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedWidget, setSelectedWidget] = useState(null);
  const [dateRange, setDateRange] = useState({ start: '2024-01', end: '2024-12' });
  const [refreshKey, setRefreshKey] = useState(0);

  return (
    <DashboardContext.Provider value={{
      searchQuery, setSearchQuery,
      selectedWidget, setSelectedWidget,
      dateRange, setDateRange,
      refreshKey, refresh: () => setRefreshKey(k => k + 1),
    }}>
      `{children}`
    </DashboardContext.Provider>
  );
}

// Used by: SearchBar (searchQuery), WidgetGrid (selectedWidget, dateRange),
// RefreshButton (refresh), 20 individual Widget components (dateRange, refreshKey)
Show Answer
// Split by update frequency
const SearchContext = createContext(null);        // Fast: every keystroke
const SelectionContext = createContext(null);     // Medium: on widget click
const DataContext = createContext(null);          // Slow: date range + refresh

function DashboardProvider({ children }) {
  const [searchQuery, setSearchQuery] = useState('');
  const [selectedWidget, setSelectedWidget] = useState(null);
  const [dateRange, setDateRange] = useState({ start: '2024-01', end: '2024-12' });
  const [refreshKey, setRefreshKey] = useState(0);

  const searchValue = useMemo(() => ({ searchQuery, setSearchQuery }), [searchQuery]);
  const selectionValue = useMemo(() => ({ selectedWidget, setSelectedWidget }), [selectedWidget]);
  const dataValue = useMemo(
    () => ({ dateRange, setDateRange, refreshKey, refresh: () => setRefreshKey(k => k + 1) }),
    [dateRange, refreshKey]
  );

  return (
    <SearchContext.Provider value={searchValue}>
      <SelectionContext.Provider value={selectionValue}>
        <DataContext.Provider value={dataValue}>
          {children}
        </DataContext.Provider>
      </SelectionContext.Provider>
    </SearchContext.Provider>
  );
}

Now typing only re-renders SearchBar (consumes SearchContext). The 20 widget components consume DataContext and only re-render when dateRange or refreshKey changes. Widget selection only affects components consuming SelectionContext.

Quiz

Quiz
Can React.memo prevent a component from re-rendering when its consumed context value changes?

Key Rules

Key Rules
  1. 1When a context value changes, ALL consumers re-render — even if they only use a subset of the value.
  2. 2Split contexts by update frequency. Fast-changing values (search, hover) should be in separate contexts from slow-changing values (theme, user).
  3. 3Always memoize Provider values: value={useMemo(() => ({ state, dispatch }), [state])}. Prevents unnecessary propagation on parent re-renders.
  4. 4Separate state from dispatch. useReducer's dispatch is referentially stable — put it in its own context for write-only consumers.
  5. 5React.memo does NOT protect against context re-renders. It only prevents parent-triggered re-renders.
  6. 6For fine-grained subscriptions (selector pattern), use external state managers (zustand, jotai) instead of context.