Prevent Unnecessary Re-Renders of Components When Using useContext with React

When managing global state in React applications, useContext is a powerful and convenient tool. However, it comes with a common pitfall: unnecessary re-renders of components subscribing to the context. In this article,I'll explore why this happens, evaluate common solutions, and ultimately present a scalable approach using useReducer to solve this problem effectively.

I do prefer with showing an example that you can find in this codesandbox

For example, in the code sandbox linked above, in the above situation all does work, and paragraph change cause console only when the button that listen to click is indeed clicked

But in App.js if you comment out <AppContainerWithContext /> and load <AppContainer />, there are two states managed in the container, one called titleText and the other called paragraphText. titleText is passed as a prop to component called TitleText and paragraphText is passed to a component called ParagraphText. Both components are wrapped in React.memo(). There are two buttons called in the AppContainer and each has a function that changes the text back and forth based on the value of separate boolean states.

This happens when a state changes in context, every app (component) accessing data from that context re-renders, even if it's not utilizing the state data that changed (because it's all ultimately passed through the value object). React.Memo does not work on values accessed from context the same way it does for properties.

The Problem: Why Does useContext Trigger Unnecessary Re-Renders?

React's useContext hook allows components to access shared state from a context provider. However, every time the context value changes, all components consuming the context re-render, even if the part of the context they rely on hasn't changed. This can lead to significant performance issues in large applications.

Example of the Issue:

Consider the following context and components:

const AppContext = createContext();

export function AppProvider({ children }) {
  const [title, setTitle] = useState("Title A");
  const [paragraph, setParagraph] = useState("Paragraph A");

  const value = { title, setTitle, paragraph, setParagraph };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

export function useAppContext() {
  return useContext(AppContext);
}

Now, suppose you have two components, as in teh codesandbox before, TitleText and ParagraphText, each consuming a specific part of the context:

const TitleText = () => {
  const { title } = useAppContext();
  return <h1>{title}</h1>;
};

const ParagraphText = () => {
  const { paragraph } = useAppContext();
  return <p>{paragraph}</p>;
};

When setTitle is called, both TitleText and ParagraphText will re-render because the entire context value object changes, even though only the title has been updated.

Common Solutions and Their Downsides

One solution is indeed as illustrated with the initial code in the sandbox.

1. Using React.memo

Wrapping components with React.memo can prevent unnecessary re-renders if the component’s props haven’t changed:

export default React.memo(TitleText);

But this approach present 2 main downsides.

Downsides:

  • Dependency Management: Ensuring props or context values passed to components are stable (e.g., wrapped with useCallback or useMemo) can quickly become cumbersome.

  • Inefficient for Large State: For complex applications, it doesn’t scale well because every component still subscribes to the entire context.

Another approach is create a Context for each “main” state that is shared among component that need those state changes and based on those state change need to re-render.

This is called “Splitting Contexts” like:

const AllContextProvider = props => {
  return (
    <UserProvider>
      <ThemeProvider>
        <NotifProvider>
          <TimelineProvider>
            <CertProvider>
              <MenusProvider>
              {props.children}
              </MenusProvider>
            </CertProvider>
          </TimelineProvider>
        </NotifProvider>
      </ThemeProvider>
    </UserProvider>
  );
};

and the using as:

const TitleContext = createContext();

const ParagraphContext = createContext();

But this approach though is completely fine present some issue:

2. Splitting Contexts

Downsides:

  • Leads to "Provider Hell" where components are wrapped in multiple providers.

  • Harder to manage and scale with more state variables.

A Scalable Solution: Refactoring to useReducer

So a better approach is to refactoring from the initial createContext setup using useState to a useReducer-based implementation provides a scalable and efficient solution I have wrote in this codesandbox.

This was considering the initial use of useState

Initial Implementation with useState

import React, { createContext, useContext, useState } from "react";

const AppContext = createContext();

export function AppProvider({ children }) { const [title, setTitle] = useState("Title A"); const [paragraph, setParagraph] = useState("Paragraph A");

const value = { title, setTitle, paragraph, setParagraph };

return <AppContext.Provider value={value}>{children}</AppContext.Provider>; }

export function useAppContext() { return useContext(AppContext); }

While functional, this implementation suffers from re-render issues because setTitle and setParagraph cause the entire value object to change, leading to re-renders of all consuming components.

Refactored Implementation with useReducer

Using useReducer allows us to centralize state management and define explicit actions for state updates, reducing unnecessary re-renders. Here’s the refactored version:

import React, { createContext, useContext, useReducer, useCallback } from "react";

// Initial state
const initialState = {
  title: "Title A",
  paragraph: "Paragraph A",
};

// Reducer function to handle state updates
const reducer = (state, action) => {
  switch (action.type) {
    case "SET_TITLE":
      return { ...state, title: action.payload };
    case "SET_PARAGRAPH":
      return { ...state, paragraph: action.payload };
    default:
      return state;
  }
};

// Create the context
const AppContext = createContext();

// Custom hook for consuming context with a selector
export function useAppContext(selector) {
  const { state } = useContext(AppContext);
  return selector ? selector(state) : state;
}

// Context provider
export function AppContextProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const setTitle = useCallback((title) => {
    dispatch({ type: "SET_TITLE", payload: title });
  }, []);

  const setParagraph = useCallback((paragraph) => {
    dispatch({ type: "SET_PARAGRAPH", payload: paragraph });
  }, []);

  const value = {
    state,
    setTitle,
    setParagraph,
  };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

Selective Subscriptions in Components

With useReducer, we can now implement selective subscriptions to ensure only the relevant parts of the state trigger re-renders:

TitleText Component

import React from "react";
import { useAppContext } from "../contexts/AppContext";

const TitleText = () => {
  console.log("TitleText Triggered");

  // Subscribe to the title part of the state
  const title = useAppContext((state) => state.title);

  return <h1>{title}</h1>;
};

export default React.memo(TitleText);

ParagraphText Component

import React from "react";
import { useAppContext } from "../contexts/AppContext";

const ParagraphText = () => {
  console.log("ParagraphText Triggered");

  // Subscribe to the paragraph part of the state
  const paragraph = useAppContext((state) => state.paragraph);

  return <p>{paragraph}</p>;
};

export default React.memo(ParagraphText);

This ensures TitleText re-renders only when the title changes, and ParagraphText re-renders only when the paragraph changes

Why This Works

  1. Centralized State Management:

    • The reducer handles all state updates in one place.
  2. Selective Subscriptions:

    • Components only subscribe to the parts of the state they need, avoiding unnecessary re-renders.
  3. Scalability:

    • Adding new state variables or actions is straightforward.
  4. Performance Optimization:

    • This pattern minimizes re-renders while maintaining simplicity and readability.

Lessons from Redux and Meta

This approach aligns with patterns established by Redux, which uses reducers and subscribable stores for efficient state management. React’s useReducer brings these concepts into its core API, offering a lightweight and performant solution without needing external libraries.

Conclusion

When using useContext, unnecessary re-renders can be a significant challenge. While React.memo and splitting contexts offer partial solutions, adopting useReducer with selective subscriptions provides a scalable, maintainable, and efficient approach. This pattern combines the best of React’s core features with lessons learned from libraries like Met's Flux and Redux, making it ideal for managing global state in modern applications.

0
Subscribe to my newsletter

Read articles from Carmine Tambascia directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Carmine Tambascia
Carmine Tambascia