reduxjs / react-redux

Official React bindings for Redux
https://react-redux.js.org
MIT License
23.37k stars 3.37k forks source link

[Performance] Slice contexts (createSliceWithContext) #2202

Closed gentlee closed 3 weeks ago

gentlee commented 3 weeks ago

What is the new or updated feature that you are suggesting?

Problem:

One of the slowest parts of redux is that there can be a lot of subscribers to the store with their custom state comparers. If we have 1000 subscribers and 100 actions are triggered per second (like on startup of the chatting app), 100 000 comparisons are made per second.

One possible solution is to use multiple stores, but this can lead to various issues.

Suggestion:

The same performance of having multiple stores can be achieved with single store. There is already an ability to make custom contexts and hooks, but currently it makes sense only for multiple stores.

UPDATED: Ability to provide the same store but with slice's context and hooks should be added with the new createSliceWithContext function:

export const {
  // new prop for slice selectors - ones that select from slice state, not the root
  // basically the same provided while creating the slice, nothing new here - just pass them here
  sliceSelectors: { 
    ...
  },
  // usual selectors can be kept as well
  selectors: { ... }
  // new props for context and provider
  context: AccountSliceContext,
  provider: AccountSliceProvider,
  hooks: {
    // new hook that connects to the new context with the slice state
    useSelector: useAccountSelector,
  },
  // other returned values are the same
  ...
} = createSliceWithContext({
  // everything is the same as in usual createSlice 
  name: 'account',
  selectors: {
    ... 
  },
  ...
})

const App = () => {
  return (
    <Provider store={store} />
      <AccountSliceProvider store={store}>
        <RootModule />
      </AccountSliceProvider>
    </Provider>
  );
}

const RootModule = () => {
  // here we got the state of the account state, not the whole redux state
  // this selector should not be executed and compared on redux state updates when account state does not change
  const accountState = useAccountSelector(accountState => accountState, customEqualityFn) 

  return null
}

Please also check the initial suggestion as it provides more generic solution, and can be implemented as well / instead:

OLD Ability to provide the same store but with domain selector should be added: ```typescript const store = createStore(reducer); const SomeDomainContext = React.createContext(null); const someDomiainSelector = (state) => state.someDomain // custom hook for a specific domain export const useSomeDomainSelector = createSelectorHook(MyContext, someDomainSelector) const App = () => { return ( ); } const RootModule = () => { // here we got the state of the selected domain, not the whole redux state // this selector should not be executed and compared on redux state updates when domain does not change const someDomainState = useSomeDomainSelector(domainState => domainState, customEqualityFn) return null } ```

Why should this feature be included?

This will make using multiple stores totally obsolete, but will fix performance issues caused by single store.

What docs changes are needed to explain this?

https://react-redux.js.org/using-react-redux/accessing-store

Add recommended practise here: https://redux.js.org/style-guide/

markerikson commented 3 weeks ago

Agreed that the O(n) behavior of Redux's subscription model is both a strength (simplicity) and a weakness (perf at very large scales).

That said, if I'm understanding this proposal right, the approach here isn't viable. There's no guarantee that a given subtree will only ever access a specific slice of state. The whole point of useSelector is that any component can read from any part of the state at any time.

gentlee commented 3 weeks ago

@markerikson you can see that someDomiainSelector is passed to createSelectorHook too, and that makes the hook provide only domain state, not the whole.

So yes, it will access only a specific slice.

And yes, this optimization makes sense only for huge apps.

markerikson commented 3 weeks ago

Yeah, that's not something we plan to implement ourselves.

(I do have a general interest in ideas to optimize React-Redux perf, but it's not something I have time to look into atm.)

gentlee commented 3 weeks ago

@markerikson OK, I updated header a bit with idea to create a createSliceWithContext, for future if someone decides to do that.

gentlee commented 3 weeks ago

Other possible ways to reach that:

  1. combineStores into single same as combineReducers / combineSlices - that way we also separate action handling by reducers for multiple stores if dispatched not by the main store, but by the child store. Which is a nice feature to have.
  2. Some wrapper (e.g. createDomain(reducer)) around reducer (including combined reducers) that returns context, hooks (useSelector, useDispatch can also be returned to separate actions only for that domain, if it is possible) and mb even selectors (if coming from combined slices) for the new context.
phryneas commented 3 weeks ago

Just to throw the idea out - with something like proxy-memoize, there could be a new version of useSelector that shared a single store subscription and then would trigger subscribers much more granularly, but transparently to the user.

markerikson commented 3 weeks ago

Yeah, that's roughly similar to what I was playing around with in this PR:

but A) I haven't ever had time to go back and play with that idea again, B) I'm pretty sure there's a ton of edge cases this would have issues with, and C) it requires an up-front fixed set of work (reconciling the immutably updated state tree into the proxy wrapper) and the cost of reading from proxies (which I recently found out can be relatively expensive), and it's very questionable if the amount of time saved from skipping subscribers and selectors would actually be a net improvement in perf.

I can say that apparently Slack did do something similar to this "domains" proposal in terms of watching for top-level slice changes, but it sounds like they did it by wrapping the store subscription process itself and providing methods for consumers to subscribe to just those keys, thus providing some fan-out behavior. (Also, evidence that this can be done in userland, and thus not something I would want to build in to the lib itself any time soon.)

In fact, I actually just went back and looked at the list of Redux addons I kept updated between 2016 and 2018, and I see several plausible-sounding "only subscribe to a specific piece of the state" libs here:

Alternately, @gentlee , you might want to look at https://www.npmjs.com/package/redux-subspace . It's deprecated, but I think the API is actually pretty similar to what you're asking for, and the README lists a few other alternative libraries.