facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
229.25k stars 46.95k forks source link

Feature request: useContextGetter #21329

Closed maclockard closed 4 months ago

maclockard commented 3 years ago

Right now the only hook for consuming a React context is useContext, which is great for most cases. However, one downside is that it results in a component re-rendering whether or not the context itself is directly used for displaying something. Take the following example:

export const ExpensiveComponent = React.memo(function ExpensiveComponent() {
  const myContext = useContext(MyContext);

  const onClick = useCallback(() => {
    doThing(myContext);
  }, [myContext]);

  // lots of other hooks

  return (
    <div>
      <button onClick={onClick}>Click Me</button>
      {/* ...other children... */}
    </div>
  );
});

Here the value of MyContext is only used when onClick is called, it is not used by any returned DOM elements or child components. However, if the value of myContext changes, ExpensiveComponent will re-render despite no differences in what is being displayed.

One way to prevent this component from over re-rendering would be to provide a hook along the lines of useContextGetter. It would prevent ExpensiveComponent from re-rendering by returning a getter function for MyContext that would allow onClick to lazily access the current context's value. This getter would be a stable function similar to the callback useState returns.

Here's the above example rewritten to use useContextGetter:

export const ExpensiveComponent = React.memo(function ExpensiveComponent() {
  const getMyContext = useContextGetter(MyContext);

  const onClick = useCallback(() => {
    doThing(getMyContext());
  }, [getMyContext]);

  // lots of other hooks

  return (
    <div>
      <button onClick={onClick}>Click Me</button>
      {/* ...other children... */}
    </div>
  );
});

There is some prior art for an API similar to this with Recoil's useRecoilCallback making it possible to access Recoil state inside of a callback without requiring a component to re-render when the state changes. One could also construct similar functionality with React Redux's useStore and calling getState() on the store inside of a callback.

The above examples I used are pretty trivial and one could simply refactor the part that uses MyContext into a separate child component to avoid re-rendering ExpensiveComponent. However, its not difficult to imagine a scenario where such a refactor may be challenging or a component being used in enough places that the re-render causes performance degradation.

markerikson commented 3 years ago

Something basically equivalent to this was previously added in https://github.com/facebook/react/pull/13139 , and then removed in https://github.com/facebook/react/pull/13861 .

You should also look at https://github.com/facebook/react/pull/20646 and https://github.com/facebook/react/pull/20890 . Not _quite the same thing as what you're proposing, but related.

maclockard commented 3 years ago

Thanks for those links! I read through them and there are definitely some interesting ideas. I think in particularly useContextSelector (#20646) would be helpful for reducing re-renders, but doesn't quite solve the same thing. Ideally useContextGetter could prevent all re-renders due to context changes, while useContextSelector still requires some part of the context to be selected on, resulting in a re-render.

Reading through https://github.com/facebook/react/issues/16956 I also find some mention of a request for a hook along the lines of useStateWithGetter (https://github.com/facebook/react/issues/16956#issuecomment-576583337). This shares a similar motivation to useContextGetter in terms of reducing how often callback references change.

vkurchatkin commented 3 years ago

You can do something like this in your code:

function createContext(defaultValue) {
  const Ctx = React.createContext(defaultValue);
  const GetterCtx = React.createContext(() => defaultValue);
  const BaseProvider = Ctx.Provider;

  function Provider({ value, children }) {
    const ref = React.useRef(value);

    React.useLayoutEffect(() => {
      ref.current = value;
    });

  const getter = React.useCallback(() => ref.current, []);

    return <BaseProvider value={value}>
      <GetterCtx.Provider value={getter}>
        {children}
      </GetterCtx.Provider>
    </BaseProvider>;
  }

  Ctx.Provider = Provider;
  Ctx.GetterCtx = GetterCtx;

  return Ctx;
}

function useContextGetter(ctx) {
  return React.useContext(ctx.GetterCtx);
}
ivorpad commented 3 years ago

Thanks @vkurchatkin I like this approach. Here's a quick demo with your code: https://stackblitz.com/edit/react-contextgetter

maclockard commented 3 years ago

@vkurchatkin That's pretty handy and I think would work for most cases, however, reading through this comment about useEventCallback I think there may be some drawbacks to using useLayoutEffect for keeping the context value up to date. Specifically if this context ref were to be used by a useLayoutEffect in child component deeper in tree, the context value would be 'stale' since child effects fire before parent effects.

If this were a part of React, I would hope that there would be some way for the context value to be updated earlier in the lifecycle than useEffect/useLayoutEffect making it safe to call in downstream effects.

github-actions[bot] commented 7 months ago

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

maclockard commented 7 months ago

This is still relevant!

github-actions[bot] commented 4 months ago

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] commented 4 months ago

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!