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

RFC: Context selectors #119

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

RFC: Context selectors #119

wants to merge 2 commits into from

Conversation

gnoff
Copy link
Contributor

@gnoff gnoff commented Jul 8, 2019

View Rendered Text

This RFC describes a new API, currently envisioned as a new hook, for allowing users to make a selection from a context value instead of the value itself. If a context value changes but the selection does not the host component would not update.

This is a new API and would likely remove the need for observedBits, an as-of-yet unreleased bailout mechanism for existing readContext APIs

For performance and consistency reasons this API would rely on changes to context propagation to make it lazier. See RFC for lazy context propagation

Motivation

In summary, adding this useContextSelector API would solve external state subscription incompatibilities with Concurrent React, eliminate a lot of complexity and code size in userland libraries, make almost any Context-using app the same speed or faster, and provide users with a more ergonomic alternative to the observedBits bailout optimization.

Addendum

Example: https://codesandbox.io/s/react-context-selectors-xzj5v
Implementation: https://github.com/gnoff/react/pull/3/files

@theKashey
Copy link

User space implementation - https://github.com/dai-shi/use-context-selector

@dantman
Copy link

dantman commented Jul 17, 2019

Hello @gnoff, I posted an alternative API idea awhile ago but never got around to writing an RFC for it and I'd like to get your opinion on it.

The idea was instead of adding a hooks specific selector was to allow creating "slices" of the contexts, which can then be used with any context api. The basic api was MyContext.slice(selector), i.e. contexts would have a new .slice method which would return a new context object. The original idea was in this comment.
Some examples:

// Static usage (possibly allows for extra internal optimizations?)
const EmailContext = UserContext.slice(user => user.email);
let MyComponent() {
  const email = useContext(EmailContext);
  // ...
}

// Dynamic usage with hooks
const KeyContext = useMemo(() => MyContext.slice(value => value[key]), [key])
const keyValue = useContext(KeyContext);

// Static usage with contextType
const EmailContext = UserContext.slice(user => user.email);
class MyComponent extends Component {
  static contextType = EmailContext;
  // ...
}

// Deep slices (imagine this is split up between different layers of an app)
const UserContext = AppState.slice(appState => appState.currentUser);
const EmailContext = UserContext.slice(user => user.email);
let MyComponent() {
  const email = useContext(EmailContext);
  // ...
}

I will admit there is one small flaw in my idea I didn't realize before. I thought this could be used universally, i.e. MyContext.slice could provide a selectable version of context that could be used across useContext, contextType, and <Consumer> and only contextType would have the limitation of being static-only. So I originally had some examples of using this with the consumer API. However I just realized that the slice api cannot be used dynamically with the consumer API. Because when you change the selector and get a new context back <SlicedContext.Consumer> will be a different component and as a result you'll get a full remount of everything inside it.

@j-f1
Copy link

j-f1 commented Jul 17, 2019

You could work around the issue with <Consumer /> by making a custom component that uses useContext to create a HOC matching Context.Consumer’s API.

@dantman
Copy link

dantman commented Jul 17, 2019

@j-f1 Interesting idea, though rather than HOC I presume you mean render function.

let Consumer = ({context, children}) => {
  const data = useContext(context);
  return children(data);
};

const KeyContext = SomeContext.slice(value => value[key]);
render(<Consumer context={KeyContext}>{keyValue => <span>{keyValue}</span>}</Consumer>);

You know, I'd almost think of recommending using something like that everywhere even in non-sliced contexts. The context API is really strange now that you think about it. When context was released <Context.Consumer> was the only way to consume a context and the best practice was to export just the consumer so you could control the provider. But now useContext and contextType instead take the context object directly. Which makes using a consumer off the context, it would make much more sense if you passed the context to the consumer instead of using a consumer from a context.

@gnoff
Copy link
Contributor Author

gnoff commented Jul 19, 2019

@dantman Interesting idea for sure. I think the way context is currently implemented internally would make some of the really dynamic stuff hard but the static stuff could probably be implemented in userland with some clever user of providers and consumers

One of the things that I don't think can be done in userland without changing the context propagation to run a bit lazier is to safely use props inside selectors for context. The issue is that during an update work may be done to change the prop so if a selector runs too early it will do so with the current prop and not the next one. this can lead to extra work being done but also errors in complicated cases where a context update is the thing itself that would change the prop.

The focus of my rfc and implementation PR is on hooks because it is the most dynamic use case. It would be relatively straight forward to add selector support for Consumer and contextType as well

As for your slice API I think you can also more or less create that on top of this without some of the challenges of creating dynamic contexts just by layering selectors and passing the result into new contexts as values

One more thing to point out, in one of my Alternatives I mention something that I think is genuinely a bit novel

// take in multiple context values, only re-render when the selected value changes
// in this case only when one of the three contexts is falsey vs when they are all truthy)
useContexts([MyContext1, MyContext2, MyContext3], (v1, v2, v3) => Boolean(v1 && v2 && v3))

Every other context optimization I've seen can only work on single context evaluations, and while I've not implemented the above api it is readily within grasp given how useContextSelector is implemented

It's almost more like a useComputedValue but by virtue of how react manages updates it can only reasonably react to changes in context values and not values in general

@PedroBern
Copy link

PedroBern commented Oct 29, 2019

Hello there, Im pasting an answer I gave originally in stackoverflow about context rerendering, what you think?

It is one way to use selectors with the context. Maybe it helps to build this api.

original link

There are some ways to avoid re-renders, also make your state management "redux-like". I will show you how I've been doing, it far from being a redux, because redux offer so many functionalities that aren't so trivial to implement, like the ability to dispatch actions to any reducer from any actions or the combineReducers and so many others.

Create your reducer

export const reducer = (state, action) => {
  ...
};

Create your ContextProvider component

export const AppContext = React.createContext({someDefaultValue})

export function ContextProvider(props) {

  const [state, dispatch] = useReducer(reducer, {
    someValue: someValue,
    someOtherValue: someOtherValue,
    setSomeValue: input => dispatch('something'),
  })

  return (
    <AppContext.Provider value={context}>
      {props.children}
    </AppContext.Provider>
  );
}

Use your ContextProvider at top level of your App, or where you want it

function App(props) {
  ...
  return(
    <AppContext>
      ...
    </AppContext>
  )
}

Write components as pure functional component

This way they will only re-render when those specific dependencies update with new values

const MyComponent = React.memo(({
    somePropFromContext,
    setSomePropFromContext,
    otherPropFromContext, 
    someRegularPropNotFromContext,  
}) => {
    ... // regular component logic
    return(
        ... // regular component return
    )
});

Have a function to select props from context (like redux map...)

function select(){
  const { someValue, otherValue, setSomeValue } = useContext(AppContext);
  return {
    somePropFromContext: someValue,
    setSomePropFromContext: setSomeValue,
    otherPropFromContext: otherValue,
  }
}

Write a connectToContext HOC

function connectToContext(WrappedComponent, select){
  return function(props){
    const selectors = select();
    return <WrappedComponent {...selectors} {...props}/>
  }
}

Put it all together

import connectToContext from ...
import AppContext from ...

const MyComponent = React.memo(...
  ...
)

function select(){
  ...
}

export default connectToContext(MyComponent, select)

Usage

<MyComponent someRegularPropNotFromContext={something} />

//inside MyComponent:
...
  <button onClick={input => setSomeValueFromContext(input)}>...
...

Demo that I did on other StackOverflow question

Demo on codesandbox

The re-render avoided

MyComponent will re-render only if the specifics props from context updates with a new value, else it will stay there.
The code inside select will run every time any value from context updates, but it does nothing and is cheap.

Other solutions

I suggest check this out Preventing rerenders with React.memo and useContext hook.

@gnoff
Copy link
Contributor Author

gnoff commented Oct 29, 2019

@PedroBern thanks for your input

I think your advice here is useful for a certain kind of optimization, even a very common one, however it does not address the main performance issue that useContextSelector is trying to eliminate.

The issue is that in your example there is a component which re-renders, the HOC that wraps MyComponent. It even tries to render MyComponent but because you have React.memo wrapped around it you bail out of rendering. The problem is with certain kinds of context updates where there may be thousands of HOCs for a single update, even this limited render is relatively expensive.

Using an implementation of react-redux that relies on context to propagate state changes you can see this by comparing a version using useContext and a version using useContextSelector. In my personal testing I was seeing update times of 40ms for useContext and 4ms for useContextSelector. This tenfold increase means the difference between jank and smooth animations.

In addition to improving performance across the board over useContext it also requires much less code to write what you want to write. For instance React.memo is required in your given solution but does not matter with useContextSelector. Also React.memo may not actually be tenable if you want to use Maps, Sets, and other mutative objects without opting into expensive copying.

Lastly I'll say that HOCs are really nice but don't compose nearly as well as hooks do so when you want to consume data from multiple contexts hook composition can be much more ergonomic.

I hope that clarifies why this RFC provides values not currently possible given existing techniques

Thanks,
Josh

@GasimGasimzada
Copy link

I like this API a lot because it focuses more on selecting values instead of bailing out of updates. I would suggest that, instead of using a separate hook, it would be more beneficial if existing useContext hook accepts function as a second argument (or is there a specific issue where this is not a good idea?) Additionally, this can work with all React concepts that support context:

class MyComponent extends React.Component {
  static contextType = MyContext;
  static contextSelector = value => value.name
}

<MyContext.Consumer selector={value => value.name}>
  ...
</MyContext.Consumer>

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Jan 6, 2020

One general optimization we could do is to eagerly call the render function during propagation and see if the render yields a different result and only if it does do we clone the path to it.

Another way to look at this selector is that it's just a way to scope a part of that eager render. Another way could be to have a hook that isolates only part of the rerender.

let x = expensiveFn(props);
let y = useIsolation(() => {
   let v = useContext(MyContext);
   return selector(v);
});
return <div>{x + y}</div>;

The interesting thing about this is that it could also be used together with state. Only if this context has changed or if this state has changed and that results in a new value, do we rerender.

let x = expensiveFn(props);
let y = useIsolation(() => {
   let [s, ...] = useState(...);
   let v = useContext(MyContext);
   return selector(v, s);
});
return <div>{x + y}</div>;

Another way to implement the same effect is to just memoize everything else instead.

let x = useMemo(() => expensiveFn(props), [props]);
let [s, ...] = useState(...);
let v = useContext(MyContext);
let y = useMemo(() => selector(v, s), [v, s]);
return useMemo(() => <div>{x + y}</div>, [x, y]);

It's hard to rely on everything else being memoized today. However, ultimately I think that this is where we're going. Albeit perhaps automated (e.g. compiler driven).

If that is the case, I wonder if this API will in fact be necessary or if it's just something that we get naturally for free by memoizing more or the other parts of a render function.

@dai-shi
Copy link

dai-shi commented Jan 6, 2020

useIsolation would be wonderful. The memoization technique may result the same effect, but it would be difficult to create a custom hook, I suppose.

@theKashey
Copy link

Hooks has a little design flaw - while they are "small" and "self-contained", their combination is not, and might update semi-randomly with updates originated from different parts.
useIsolation might solve this problem.

@GasimGasimzada
Copy link

Doesn’t this break the Rules of Hooks? If this is implemented, isn’t it going to confuse developers, especially newcomers: “do not call Hooks inside nested functions but you can call it inside useIsolation.”

@gnoff
Copy link
Contributor Author

gnoff commented Jan 7, 2020

@sebmarkbage

One general optimization we could do is to eagerly call the render function during propagation and see if the render yields a different result and only if it does do we clone the path to it.

Unless i misunderstand this would still rely on the function memoizing the rendered result otherwise equivalent renders would still have different results. Then we'll get into territory where everyone is always memoizing rendered results as a lazy opt-in to maybe sometimes more efficient context propagation. Also unless you pair this with lazy context propagation your render will be using previous props so you may end up calling it multiple time in a single work loop with different props and recompute memo'd values / expensive functions.

The thing that makes useContextSelector fast is that it guarantees single execution because it defers to work that would have been done anyway and ONLY does context selector checking if all regular work has been exhausted

Another way could be to have a hook that isolates only part of the rerender.

This is super cool. it's like useContexts(Context1, Context2, ...) but it can also use state and is more ergonomic. In theory you could actually nest them so there are isolations within your isolation so they can still compose nicely with custom hooks.

Again though, unless you have lazy context propagation things like useReducer are going to deop a lot b/c the props you see during the propagation call are not necessarily the ones you will get once you do the render

I understand the concerns around rules of hooks etc... and it is definitely a little confusing to teach the 'exception' in a way but payoff may be worth it.

The biggest downside I see is cognitive load. useContextSelector is pretty WYSIWYG. useIsolation is named after what it does in a way but only if you understand what React is doing. may be better to do useValue or something

interestingly there may be a way to combine this with useMemo so we don't need a new hook. if you start using state and contexts inside memos they can reset the cache and schedule work allowing them to trigger updates but the dependency array semantics can work for all other normal cases where internal state / context values haven't changed.

@mgenev
Copy link

mgenev commented Jan 15, 2020

Doesn’t this break the Rules of Hooks? If this is implemented, isn’t it going to confuse developers, especially newcomers: “do not call Hooks inside nested functions but you can call it inside useIsolation.”

Personally, it does not confuse me. It makes everything much better and I'm adopting the userland module for it already.

@kristijorgji
Copy link

Any update on this ?

currently using this polyfill until this becomes native
https://www.npmjs.com/package/@fluentui/react-context-selector

@gaearon
Copy link
Member

gaearon commented Sep 8, 2021

If there was an update, it would have been posted. :-) Please don’t leave comments like “is there an update” because they only create notification noise for others.

@markerikson
Copy link

I recently asked about the status of the context selectors work over in the React 18 Working Group discussion forum:

reactwg/react-18#73

Per Andrew's comment:

We ran an experiment of the lazy propagation mechanism that showed mildly positive performance results, but we haven't run an experiment for context selectors yet. We're not really blocked by anything, it just hasn't been a priority while we work on other 18-related projects.

@alexburner
Copy link

@kristijorgji FWIW the react-context-selector library you linked "uses undocumented feature of calculateChangedBits" https://github.com/microsoft/fluentui/tree/master/packages/react-context-selector#technical-memo

This similar use-context-selector library (listed as inspiration to the above) was updated to avoid that:

Prior to v1.3, it uses changedBits=0 feature to stop propagation, v1.3 no longer depends on this undocumented feature.

So I'm guessing it may be a safer polyfill

@Ayc0 Ayc0 mentioned this pull request Dec 13, 2023
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 26, 2024
This user-land library improves performance by removing unnecessary 
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their 
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant 
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 26, 2024
This user-land library improves performance by removing unnecessary 
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their 
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant 
information about the performance issues with the context API.
paullinator pushed a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 27, 2024
This user-land library improves performance by removing unnecessary 
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their 
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant 
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 29, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 29, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 29, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 29, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 29, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
samholmes added a commit to EdgeApp/edge-react-gui that referenced this pull request Jan 30, 2024
This user-land library improves performance by removing unnecessary
re-renders to components which use global contexts. By selecting the
state that the components use from the contexts, they'll limit their
re-rendering to only when that state changes.

See reactjs/rfcs#119 for more relevant
information about the performance issues with the context API.
@rostero1
Copy link

Is this going to be the solution for React 19?

const useFoo = () => {
  return useMemo(() => {
    const { foo } = use(MyContext)
    return foo
  }, [])
}

Reference

@stephan-noel
Copy link

This won't be supported in the initial release of React 19 it seems:

image

Source

Also I think they're looking to optimize out useMemo:

image

Source

I realize this probably isn't a priority right now, but if there is space for helping out, I'd be glad to do so in whatever form.

I'm also interested in if it's being blocked by Forget and if there will be a way to still use useMemo in the cases where Forget may not be able to compile properly due to breaking some of the compiler-specific rules (ie, reactjs/react.dev#6680 (comment)).

@eduardocque
Copy link

hi @gnoff any news about this RFC, i believe this will be a game changes for react and context/provider API, for complex aplications this will be nice for performance and re-rendering perspective

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.