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
oruseMemo
) 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
Centralized State Management:
- The reducer handles all state updates in one place.
Selective Subscriptions:
- Components only subscribe to the parts of the state they need, avoiding unnecessary re-renders.
Scalability:
- Adding new state variables or actions is straightforward.
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.
Subscribe to my newsletter
Read articles from Carmine Tambascia directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
