Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature request: useContextGetter #21329

Open
maclockard opened this issue Apr 22, 2021 · 7 comments
Open

Feature request: useContextGetter #21329

maclockard opened this issue Apr 22, 2021 · 7 comments
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@maclockard
Copy link

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.

@maclockard maclockard added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Apr 22, 2021
@markerikson
Copy link
Contributor

Something basically equivalent to this was previously added in #13139 , and then removed in #13861 .

You should also look at #20646 and #20890 . Not _quite the same thing as what you're proposing, but related.

@maclockard
Copy link
Author

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 #16956 I also find some mention of a request for a hook along the lines of useStateWithGetter (#16956 (comment)). This shares a similar motivation to useContextGetter in terms of reducing how often callback references change.

@vkurchatkin
Copy link

vkurchatkin commented Apr 23, 2021

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
Copy link

ivorpad commented Apr 23, 2021

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

@maclockard
Copy link
Author

@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.

Copy link

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 github-actions bot added the Resolution: Stale Automatically closed due to inactivity label Apr 10, 2024
@maclockard
Copy link
Author

This is still relevant!

@github-actions github-actions bot removed the Resolution: Stale Automatically closed due to inactivity label Apr 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

No branches or pull requests

4 participants