Home > Software design >  React Context Consumer re-renders even if the Provider gives the same context value?
React Context Consumer re-renders even if the Provider gives the same context value?

Time:08-08

I think the general rule is: the context consumers only re-render when the context value has changed. The rule:

https://reactjs.org/docs/context.html#contextprovider

All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes. The propagation from Provider to its descendant consumers (including .contextType and useContext) is not subject to the shouldComponentUpdate method, so the consumer is updated even when an ancestor component skips an update.

Changes are determined by comparing the new and old values using the same algorithm as Object.is.

However, on this sample app, https://codesandbox.io/s/loving-wood-dzqm9g (to be able to see the app running, you may need to bring it to its own window: https://dzqm9g.csb.app/ ), every component re-renders regardless the context value has changed or not.

The first Context.Provider actually changes the value:

      <ThemeContext.Provider
        value={toggle ? themeColors.light : themeColors.dark}
      >

so it is reasonable that the consumer underneath it is re-rendered. But the <TheTimeNow /> is not a consumer, and is re-rendered for some reason. The time is updated in the box every time the Toggle button is pressed.

And even the second Context.Provider:

      <ThemeContext.Provider value={themeColors.light}>
        <div
          className="App"
          style={{ border: "3px dotted #07f", margin: "18px" }}
        >
          <Container />
        </div>

        <TheTimeNow />
      </ThemeContext.Provider>

The context value does not change at all, yet all the components are re-rendered.

To make it one step further, I outright provided a constant context value and use only one context provider:

https://codesandbox.io/s/cranky-sunset-3sbhr4?file=/src/App.js

And I am still able to make every component re-render, as we can see the time updated in every component.

So is it against the rule mentioned at the top of this post? Or is it true that we also have this rule: every children component re-renders when the parent re-renders (and therefore the whole subtree re-renders ? So because <App /> re-renders, everything underneath re-renders?

So when both rules apply, then it ended up all children in the subtree re-renders.

However, how would we make a <Context.Provider> change its value, but have this container component not re-render? I cannot think of a case because we usually use a state or props to cause the context value to change and re-render this component, so the whole subtree will re-render. How is it possible the value change but this container doesn't re-render?

In other words, for <ThemeContext.Provider value={someValue}> to give a different value, this <ThemeContext.Provider> component must be rendering and therefore, the component containing this line is also re-rendering, and so all children, and even the whole subtree would re-render. Then why would we say, "only the context consumers will re-render"?

CodePudding user response:

Your toggle state is managed at the app root, which means whenever any children update that state, the entire app re-renders:

function App() {
  // This state is managed _outside_ the ThemeContext
  const [toggle, setToggle] = useState(true);
  return (
    <div>
      <ThemeContext.Provider
        value={toggle ? themeColors.light : themeColors.dark}
      >
    ...

ThemeContext.Provider is consuming the state from the App, but you want the state to be managed locally to the Provider. If you create a ThemeProvider component to encapsulate the theme state, I think you'll have what you're looking for: only the consumers update when the theme changes.

I wasn't able to fork your CodeSandbox for some reason, but created a simple example on StackBlitz: https://stackblitz.com/edit/react-ts-286rhj?file=App.tsx

Here's the relevant code:

export const ThemeContext = React.createContext();

export function ThemeProvider({ children }) {
  // State changes here only affect context consumers
  const [toggle, setToggle] = React.useState(true);

  const theme = toggle ? 'light' : 'dark';
  const toggleTheme = () => setToggle((t) => !t);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

ConsumingChild contains useContext(ThemeContext), and updates when toggle changes. NonConsumingChild doesn't consume context, and doesn't re-render when toggle changes.

function App() {
  return (
    <ThemeProvider>
      <ConsumingChild />
      <NonConsumingChild />
    </ThemeProvider>
  )
}

Edit: If you want a button to change the theme and not re-render anything around it, you can create a Button component which can go anywhere in your app as long as it's within the ThemeProvider:

export function ThemeButton() {
  const {toggleTheme} = useContext(ThemeContext)

  return (
    <button onClick={toggleTheme}>
      Toggle the theme
    </button>
  )
}

And here's the consuming child:

export function ConsumingChild() {
  const {theme} = useContext(ThemeContext);

  return (
    <>
      <div>The current theme is {theme}</div>
      <ThemeButton />
    </>
  )
}

Edit: As you mention in a comment, the ThemeProvider value will be a different reference on every render so all consumers will re-render when a change is made to any of them. There are ways to optimize this (for example see this article and this thread), but follow the advice in the article and make sure you need to optimize renders before going through the process, because your app might be as fast as needed without that optimization.

CodePudding user response:

In your case the time component is updating because its parent component (the App component) state changes when you click the button. This triggers a rerender of the whole component as usual in React.

You are wrong about your assumption that context consumers only rerenders when the context has changed. Two paragraphs above in the React docs:

Every Context object comes with a Provider React component that allows consuming components to subscribe to context changes.

This means that every child component of the context provider is subscribed to context changes. This means that every child component no matter how deep the level will update when the context updates.

React considers all descendent components of the context providers to be consumers not only the components that use useContext!

I think you are confused by this sentence:

The propagation from Provider to its descendant consumers (including .contextType and useContext) is not subject to the shouldComponentUpdate method, so the consumer is updated even when an ancestor component skips an update.

This only means that descendent components of a component where the shouldComponentUpdateMethod returns false (implying that the component should not be updated) are still being updated with the new value of the context.

If you are looking for a way to prevent a component from updating when none of it props change (the same prop values will always produce the same output) then have a look at React Memo Components.

In your provided codesandbox if you refactor the code of TheTimeNow.jsx to be a Memo component you will see that the time won't get updated when you press the toggle button, but the colors will change.

import React from "react";

export default React.memo(function TheTimeNow() {
  return (
    <h2>TheTimeNow component says it is {new Date().toLocaleTimeString()}</h2>
  );
});
  • Related