Reputation: 665
I've seen a few questions related to this topic, but none that tackle the issue head-on in a pure way. useContext is a great tool for minimizing prop-drilling and centralizing key data needed across your app; however, it comes at a cost that I'm trying to minimize.
The closest issue to the one I'm describing here was asked two years ago here. The question didn't gain a lot of traction and the only answer basically says call the context in a parent container and pass values down through props (defeats the purpose of context) or use Redux. Maybe that's the only way, but I wanted to bring the question back to the collective to see if there is a better answer that's not dependent on external libraries.
I've set up a code sandbox here to illustrate the issue. Re-renders are console logged out to make seeing them easier.
In short, when a state changes in context, every app 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.
For example, in the code sandbox linked above, 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.
Here is the function that toggles the titleText, the one for paragraph text is the same logic:
const changeTitleHandler = useCallback(() => {
const title = listTitleToggle ? "Title B" : "Title A";
setListTitleToggle((pV) => !pV)
setTitleText(title);
}, [listTitleToggle]);
Since the titleText
component and paragraphText
components are wrapped with React.useMemo
, they only re-render when the corresponding value passed to them changes. Perfect.
Now in App.js
if you comment out the <AppContainer />
component and enable the <AppContainerWithContext />
component, the rendered output and result of button clicks is identical; however, the states that change and are rendered on the screen are now managed by AppContext (called contextTitleText
and contextParagraphText
and passed to the TitleText
component and ParagraphText
component via useContext.
Now, if you click on the button to toggle the title, the ParagraphText
component re-renders too, even though it doesn't use the contextTitleText
state. My understanding of why this happens is because the value object changes when the contextTitleText is updated, causing any component accessing that value object through useContext to re-render.
My question is this:
Is there a way to utilize useContext without causing re-renders on all components accessing the context. In the example above, can we utilize useContext to manage the contextTitleText
and the contextParagraphText
but only re-render the components where the state from context being accessed changes?
Upvotes: 9
Views: 5297
Reputation: 1938
A way to prevent re-rendering without use nested Context that could "explode" as happens every time in solutions like this is to take advantage of the same mecanism that is used in store manager libraries, the reducer.
Indeed at Meta was implemented such hook for such purpose, I do guess.
Here the solution, I attached the 3 main file rewriting them and a codesandbox I forked starting from yours:
import React, {
createContext,
useContext,
useReducer,
useCallback,
} from "react";
// Initial state
const initialState = {
title: "Title A",
paragraph: "Paragraph A",
};
// Reducer function to manage 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 to consume context
export function useAppContext(selector) {
const { state } = useContext(AppContext);
// If a selector is provided, return the selected part of the state
if (selector) {
return selector(state);
}
// Otherwise, return the entire state
return state;
}
// Provider component
export function AppContextProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
// Define the setTitle action
const setTitle = useCallback((title) => {
dispatch({ type: "SET_TITLE", payload: title });
}, []);
// Define the setParagraph action
const setParagraph = useCallback((paragraph) => {
dispatch({ type: "SET_PARAGRAPH", payload: paragraph });
}, []);
// Provide state and actions to consumers
const value = {
state, // Provides the current state
setTitle, // Provides the setTitle action
setParagraph, // Provides the setParagraph action
};
// debugging
console.log("Provided Context Value:", value);
console.log("Provided Context Value in AppContextProvider:", {
setTitle,
setParagraph,
});
return <AppContext.Provider value={value}>{children}
</AppContext.Provider>;
}
Then of course there is a need to slighly adapt the Component that handle the title toggle and the paragraph because in your code they use useState(), but nothing really difficult.
I have attached the working solution in useContext without extra rendering
Upvotes: 0