Avoid unnecessary re-renders with React hook useContext
Asked Answered
D

1

2

I want to find a way to avoid unnecessary re-renders when using useContext. I've created a toy sandbox, which you can find here: https://codesandbox.io/s/eager-mclean-oupkx to demonstrate the problem. If you open the dev console, you'll see the console.log happening every second.

In this example, I have a context that simply increments a counter every 1 second. I then have a component that consumes it. I don't want every tick of the counter to cause a re-render on the component. For example, I may just want to update my component every 10 ticks, or I may want some other component that updates with every tick. I tried creating a hook to wrap useContext, and then return a static value, hoping that would prevent the re-renders, to no avail.

The best way I can think of to overcome this issue is to do something like wrap my Component in a HOC, which consumes count via useContext, and then passes the value into Component as a prop. This seems like a somewhat roundabout way of accomplishing something that should be simple.

Is there a better way to do this?

Desuetude answered 14/4, 2020 at 3:4 Comment(1)
Feels like you're trying to buck the system a bit with this super simplified demo. React hooks run every render, and functional components rerender whenever state or props update. I agree here though that if you didn't want a component to rerender with every update of the context then factor that out and pass as a prop. The react memo would be the HOC to help try to cut down on extraneous renders (though by definition they aren't extraneous at all as they render exactly by design!!)Critchfield
S
3

Components that call useContext directly or indirectly through custom hook will re render every time value of the provider changes.

You can call useContext from a container and let the container render a pure component or have the container call a functional component that is memoized with useMemo.

In the example below count is changed every 100 milliseconds but because useMyContext returns Math.floor(count / 10) the components will only render once every second. The containers will "render" every 100 milliseconds but they will return the same jsx 9 out of 10 times.

const {
  useEffect,
  useMemo,
  useState,
  useContext,
  memo,
} = React;
const MyContext = React.createContext({ count: 0 });
function useMyContext() {
  const { count } = useContext(MyContext);

  return Math.floor(count / 10);
}

// simple context that increments a timer
const Context = ({ children }) => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const i = setInterval(() => {
      setCount((c) => c + 1);
    }, [100]);
    return () => clearInterval(i);
  }, []);

  const value = useMemo(() => ({ count }), [count]);
  return (
    <MyContext.Provider value={value}>
      {children}
    </MyContext.Provider>
  );
};
//pure component
const MemoComponent = memo(function Component({ count }) {
  console.log('in memo', count);
  return <div>MemoComponent {count}</div>;
});
//functional component
function FunctionComponent({ count }) {
  console.log('in function', count);
  return <div>Function Component {count}</div>;
}

// container that will run every time context changes
const ComponentContainer1 = () => {
  const count = useMyContext();
  return <MemoComponent count={count} />;
};
// second container that will run every time context changes
const ComponentContainer2 = () => {
  const count = useMyContext();
  //using useMemo to not re render functional component
  return useMemo(() => FunctionComponent({ count }), [
    count,
  ]);
};

function App() {
  console.log('App rendered only once');
  return (
    <Context>
      <ComponentContainer1 />
      <ComponentContainer2 />
    </Context>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>


<div id="root"></div>

With React-redux useSelector a component that calls useSelector will only be re rendered when the function passed to useSelector returns something different than the last time it was run and all functions passed to useSelector will run when redux store changes. So here you don't have to split container and presentation to prevent re render.

Components will also re render if their parent re renders and passes them different props or if parent re renders and the component is a functional component (functional components always re render). But since in the code example the parent (App) never re renders we can define the containers as functional components (no need to wrap in React.memo).

The following is an example of how you can create a connected component similar to react redux connect but instead it'll connect to context:

const {
  useMemo,
  useState,
  useContext,
  useRef,
  useCallback,
} = React;
const { createSelector } = Reselect;
const MyContext = React.createContext({ count: 0 });
//react-redux connect like function to connect component to context
const connect = (context) => (mapContextToProps) => {
  const select = (selector, state, props) => {
    if (selector.current !== mapContextToProps) {
      return selector.current(state, props);
    }
    const result = mapContextToProps(state, props);
    if (typeof result === 'function') {
      selector.current = result;
      return select(selector, state, props);
    }
    return result;
  };
  return (Component) => (props) => {
    const selector = useRef(mapContextToProps);
    const contextValue = useContext(context);
    const contextProps = select(
      selector,
      contextValue,
      props
    );
    return useMemo(() => {
      const combinedProps = {
        ...props,
        ...contextProps,
      };
      return (
        <React.Fragment>
          <Component {...combinedProps} />
        </React.Fragment>
      );
    }, [contextProps, props]);
  };
};
//connect function that connects to MyContext
const connectMyContext = connect(MyContext);
// simple context that increments a timer
const Context = ({ children }) => {
  const [count, setCount] = useState(0);
  const increment = useCallback(
    () => setCount((c) => c + 1),
    []
  );
  return (
    <MyContext.Provider value={{ count, increment }}>
      {children}
    </MyContext.Provider>
  );
};
//functional component
function FunctionComponent({ count }) {
  console.log('in function', count);
  return <div>Function Component {count}</div>;
}
//selectors
const selectCount = (state) => state.count;
const selectIncrement = (state) => state.increment;
const selectCountDiveded = createSelector(
  selectCount,
  (_, divisor) => divisor,
  (count, { divisor }) => Math.floor(count / divisor)
);
const createSelectConnectedContext = () =>
  createSelector(selectCountDiveded, (count) => ({
    count,
  }));
//connected component
const ConnectedComponent = connectMyContext(
  createSelectConnectedContext
)(FunctionComponent);
//app is also connected but won't re render when count changes
//  it only gets increment and that never changes
const App = connectMyContext(
  createSelector(selectIncrement, (increment) => ({
    increment,
  }))
)(function App({ increment }) {
  const [divisor, setDivisor] = useState(0.5);
  return (
    <div>
      <button onClick={increment}>increment</button>
      <button onClick={() => setDivisor((d) => d * 2)}>
        double divisor
      </button>
      <ConnectedComponent divisor={divisor} />
      <ConnectedComponent divisor={divisor * 2} />
      <ConnectedComponent divisor={divisor * 4} />
    </div>
  );
});

ReactDOM.render(
  <Context>
    <App />
  </Context>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>


<div id="root"></div>
Shod answered 14/4, 2020 at 7:54 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.