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

Rewrite connect() for better performance and extensibility #416

Closed
wants to merge 76 commits into from
Closed

Rewrite connect() for better performance and extensibility #416

wants to merge 76 commits into from

Conversation

jimbolla
Copy link
Contributor

@jimbolla jimbolla commented Jun 24, 2016

Update: Released as alpha!

See below. You can now install this as react-redux@next:

npm install --save react-redux@next

Please test it out!

TL;DR

Rewrote connect, same basic API plus advanced options/API, all tests pass, roughly 8x faster, more modular/extensible design

Overview

I rewrote connect into modular pieces because I wanted to be able to extend with custom behavior in my own projects. Now connect is a facade around connectAdvanced, by passing it a compatible selectorFactory function.

I also was able to greatly improve performance by changing the store subscriptions to execute top-down to work with React's natural flow of updates; component instances lower in the tree always get updated after those above them, avoiding unnecessary re-renders.

Design/Architecture

I split the original connect into many functions+files to compartmentalize concepts for better readability and extensibility. The important pieces:

  • components/
    • connectAdvanced.js: the HOC that connects to the store and determines when to re-render
    • Provider.js: (hasn't changed)
  • selectors/
    • connect.js: composes the other functions into a fully-compatible API, by creating a selectorFactory and options object to pass to connectAdvanced.
      that performs memoiztion and detects if the first run returns another function, indicating a factory
    • mapDispatchToProps.js: used to create a selector factory from the mapDispatchToProps parameter, to be passed to selectorFactory.js as initMapDispatchToProps. Detects whether mapDispatchToProps is missing, an object, or a function
    • mapStateToProps.js: used to create a selector factory from the mapStateToProps parameter, to be passed to selectorFactory.js as initMapStateToProps. Detects whether mapStateToProps is missing or a function
    • mergeProps.js: used to create a selector factory from the mergeProps parameter, to be passed to selectorFactory.js as initMergeProps. Detects whether mergeProps is missing or a function.
  • selectorFactory.js: given dispatch, pure, initMapStateToProps, initMapDispatchToProps, and initMergeProps, creates a connectAdvanced-compatible selector
  • wrapMapToProps.js: helper functions for wrapping values of mapStateToProps and mapDispatchToProps in compatible selector factories

  • utils/
    • Subscription.js: encapsulates the hierachial subscription concept. used by connectAdvanced.js to pass a parent's store Subscription to its children via context
    • verifyPlainObject.js: used to show a warning if mapStateToProps, mapDispatchToProps, or mergeProps returns something other than a plain object

file graph

graph

digraph files {
  "connectAdvanced.js" -> "React"
  "connectAdvanced.js" -> "Subscription.js"

  "connect.js" -> "connectAdvanced.js"
  "connect.js" -> "mapDispatchToProps.js"
  "connect.js" -> "mapStateToProps.js"
  "connect.js" -> "mergeProps.js"
  "connect.js" -> "selectorFactory.js"

  "mapDispatchToProps.js" -> "wrapMapToProps.js"
  "mapStateToProps.js" -> "wrapMapToProps.js"
}

The modular structure of all the functions in connect/ should allow greater reuse for anyone that wants to create their own connect variant. For example, one could create a variant that handles when mapStateToProps is an object by using reselect's createStructuredSelector:

customConnect.js:

import connect from 'react-redux/lib/connect/connect'
import defaultMapStateToPropsFactories from 'react-redux/lib/connect/mapStateToProps'

function createStatePropsSelectorFromObject(mapStateToProps) {
  const initSelector = () => createStructuredSelector(mapStateToProps)
  initSelector.dependsOnProps = true
  return initSelector
}

function whenStateIsObject(mapStateToProps) {
  return (mapStateToProps && typeof mapStateToProps === 'object')
    ? createStatePropsSelectorFromObject(mapStateToProps)
    : undefined 
  }
}

export default function customConnect(mapStateToProps, mapDispatchToProps, mergeProps, options) {
  return connect(mapStateToProps, mapDispatchToProps, mergeProps, {
    ...options,
    mapStateToPropsFactories: defaultMapStateFactories.concat(whenStateIsObject)
  })
}

ExampleComponent.js

// ...
export default customConnect({
  thing: thingSelector,
  thong: thongSelector
})(ExampleComponent)

And for scenarios where connect's three-function API is too constrictive, one can directly call, or build a wrapper around, connectAdvanced where they have full control over turning state + props + dispatch into a props object.

Performance

I'm using a modified version of react-redux-perf to performance test+profile the changes. It's configured to try to fire up to 200 actions per second (but becomes CPU bound), with 301 connected components. There are 2 scenarios being tested:

  • NB: a parent component with 300 child components, with no other React components between them.
  • WB: the same setup as NB but there's a "Blocker" React component between the parent and children that always returns false for shouldComponentUpdate.

I measured the milliseconds needed to render a frame using the stats.js used by react-redux-perf:

MS: avg (min-max) current NB rewrite NB current WB rewrite WB
Chrome 170 (159-223) 20 (17-55) 170 (167-231) 17 (15-59)
Firefox 370 (331-567) 20 (16-51) 430 (371-606) 19 (15-60)
IE11 270 (127-301) 40 (36-128) 300 (129-323) 33 (30-124)
Edge 240 (220-371) 37 (32-102) 260 (97-318) 28 (24-100)

On the conservitive end, the rewrite is about 8x faster under these circumstances, with Firefox even doubling that improvement. Much of the perf gains are attributed to avoiding calls to setState() after a store update unless a re-render is necessary.

In order to make that work with nested connected components, store subscriptions were changed from "sideways" to top-down; parent components always update before their child components. Connected components detected whether they are nested by looking for an object of type Subscription in the React context with the key storeSubscription. This allows Subscription objects build into a composite pattern.

After that I've used Chrome and Firefox's profilers to watch for functions that could be optimized. At this point, the most expensive method is shallowEqual, accounting for 4% and 1.5% CPU in Chrome and Firefox, respectively.

connectAdvanced(selectorFactory, options) API

In addition to the changed related to performance, the other key change is an additional API for connectAdvanced(). connectAdvanced is now the base for connect but is less opinionated about how to combine state, props, and dispatch. It makes no assumptions about defaults or intermediate memoization of results, and leaves those concerns up to the caller. It does memoize the inbound and outbound props objects. A full signature for connectAdvanced with its selectorFactory would look like:

export default connectAdvanced(
  // selectorFactory receives dispatch and lots of metadata as named parameters. Most won't be
  // useful to a direct caller, but could be useful to a library author wrapping connectAdvanced
  (dispatch, options) => (nextState, nextProps) => ({
    someProp: //...
    anotherProp: //...
  }),
  // options with their defaults:
  {
    getDisplayName: name => `ConnectAdvanced(${name})`,
    methodName: 'connectAdvanced',
    renderCountProp: undefined,
    shouldHandleStateChange: true,
    storeKey: 'store',
    withRef: false,
    // you can also add extra options that will be passed through to your selectorFactory:
    custom1: 'hey',
    fizzbuzz: fizzbuzzFunc
  }
)(MyComponent)

A simple usage may look like:

export default connectAdvanced(dispatch => {
  const bound = bindActionCreators(actionCreators, dispatch)
  return (store, props) => ({
    thing: state.things[props.thingId],
    saveThing: bound.saveThing
  })
})(MyComponent)

An example using reselect to create a bound actionCreator with a prop partially bound:

export default connectAdvanced(dispatch => {
  return createStructuredSelector({
    thing: (state, props) => state.things[props.thingId],
    saveThing: createSelector(
      (_, props) => props.thingId,
      (thingId) => inputs => dispatch(actionCreators.saveThing(thingId, inputs))
    )
  })
})(MyComponent)

An example doing custom memoization with actionCreator with a prop partially bound:

export default connectAdvanced(dispatch => {
  return createStructuredSelector({
    let thingId
    let thing
    let result
    const saveThing = inputs => dispatch(actionCreators.saveThing(thingId, inputs))

    return (nextState, nextProps) => {
      const nextThingId = nextProps.thingId
      const nextThing = nextState.things[nextThingId]
      if (nextThingId !== thingId || nextThing !== thing) {
        thingId = nextThingId
        thing = nextThing
        result = { thing, saveThing }
      }
      return result
    }
  })
})(MyComponent)

Note these are meant as examples and not necessarily "best practices."

Pros/cons

I understand there is great risk to accepting such drastic changes, that would have to be justified with significant benefits. I'll reiterate the two main benefits I believe these changes offer:

  1. Performance: There's potentially huge perf gains in situations where the number of connected components is high, stemming from conceptual changes to subscriptions so they go with the natural flow of events in React vs across them, as well as method profiling+optimizing using Chrome/FF.
  2. Extensibility/Maintainability: By splitting the responibilities of connect into many smaller functions, it should be easier both for react-redux contributors to work with the codebase and end users to extend its functionality though the additional APIs. If users can add their desired features in their own projects, that will reduce the number of feature requests to the core project.

Despite passing all the automated tests as well as week of manual testing, there is risk of impacting users dependent on implicit behavior, or that performance is worse in some unexpected circumstances. To minimize risk of impacting end users and downstream library authors, I think it would be wise to pull these changes into a "next" branch and first release an alpha package. This would give early adopters a chance to test it and provide feedback

Thanks

I'd like to thank the other github users who have so far offered feedback on these changes in #407, especially @markerikson who has gone above and beyond.

…ops pass. BREAKING CHANGE: requires setting an option parameter mapStateIsFactory/mapDispatchIsFactory to signal factory methods.
…hind-the-scenes behavior of new implementation.
…omponents always subscribe before child components, so that parents always update before children.
…ead of subscribing to the store directly they subscribe via their ancestor connected component. This ensures the ancestor gets to update before its children.
…st compare last rendered props to new selected props
@chrisvasz
Copy link

Love this! I just dropped the beta into an existing application that uses Redux to store form information. In my application, each field is connected to the store, similar to redux-form's approach after the v6 upgrade. I loaded a fairly complex form and noticed with Perf.printWasted() that each field was performing a "wasted" render each time any other field changed. This quickly adds up to hundreds of wasted renders on a medium-sized form. (Imagine that each character in an <input> causes a state change in the store. For an average typist, that is 3-4 characters per second. Multiply that by the number of form fields and you arrive at hundreds of wasted renders per second on a form with just 25 fields, which is a small number for our use case.)

With the new react-redux, against the exact same code, the hundreds of wasted renders have dropped to zero! Love it! Way to go @jimbolla!

@Mike-Sinistralis
Copy link

Mike-Sinistralis commented Sep 30, 2016

Dropped this into the same application I tested earlier. This test was performed on mock data, so the data is slightly different but the structure, relationship, etc of the data are all the same across both.

The test was a drag and drop implementation using lists and smart components. The difference with this change is MASSIVE in terms of render count. (This is on Chrome, which seems to be the browser best able to handle this scenario. Mobile fares much, much worse)

Before:
capture

After:
capture2

@slorber
Copy link
Contributor

slorber commented Oct 1, 2016

Hi,

This is good news, but can someone explain the performance improvements? I'm not sure to understand why it performs better than before as a drop-in replacement

@markerikson
Copy link
Contributor

@jimbolla can explain further, but it's a combination of several factors. Primarily, there's a ton of custom memoization going on using selectors, which avoids the need to call setState. v4, on the other hand, called setState and then did the real checking in the wrapper's render function. It also ensures that subscriptions are handled top-down, fixing a loophole where children could get notified by the store before ancestors and causing unnecessary render checks. See the rest of this thread, as well as #407 for discussion and details.

@jimbolla
Copy link
Contributor Author

jimbolla commented Oct 1, 2016

@slorber @markerikson The short short version... Forcing the order of components' store subscriptions to trigger top-down allows connect to avoid extra calls to setState, render, and mapStateToProps'n'friends.

@Mike-Sinistralis
Copy link

Doesn't that mean it also fixes the issue where can you end up getting components in in-determinant states because they render before their parent, which could result in a child trying to access non-existent data because they haven't been unmounted yet?

I've actually run into this problem, and tested to see if the issue still occurs with v5 and it does not, so another issue fixed?

@markerikson
Copy link
Contributor

Y'know, that's a good point. I've seen that in my own prototype app, where I frequently use connected parents rendering connected list items. I bet there's a good chance v5 resolves that.

@jimbolla
Copy link
Contributor Author

jimbolla commented Oct 1, 2016

Yep. That was actually the motivation for making the change. The perf was a nice surprise.

@slorber
Copy link
Contributor

slorber commented Oct 3, 2016

That's nice! so finally it somehow breaks compatibility (not in a way that's likely to break someone's app) and the new behavior makes connect more performant, in addition to being more flexible.

However @jimbolla can you take a look at this usecase of a list of 100k connected elements? http://stackoverflow.com/a/34788570/82609
I've seen your comments pointing out that the new implementation will permit to solve this problem in an efficient way, but I'm not sure how it can be done. Any code to share?

@jimbolla
Copy link
Contributor Author

jimbolla commented Oct 4, 2016

@slorber A few thoughts on that:

  • Can squeeze some extra perf by using connectAdvanced with a custom selector instead of connect. It would probably be worth it at 100k connected components.
  • Another option besides those you suggested would be grouping your components into pages of (let's say) 100, so instead of 100k connected components, you'd have 1000 connected pages containing 100 unconnected components. Not sure if this would be faster or not. But it's worth experimenting
  • In v5, connect/connectAdvanced take an option shouldHandleStateChanges. (connect sets it based on whether mapStateToProps is provided, connectAdvanced defaults it to true) You could connect a component that reads from state, but doesn't subscribe to store changes. This means it would only trigger rerenders when it receives new props from above. This may be be faster or slower.

@jide
Copy link

jide commented Oct 5, 2016

Hi !

Since I updated a project to react-redux 5.0.0-beta.2, I see this error popping up, any clue ?
capture d ecran 2016-10-05 a 15 43 28
Which comes from :

      if (typeof ownProps[key] !== 'undefined') {
        (0, _warning2.default)(false, 'Duplicate key ' + key + ' sent from both parent and state');
        break;
      }

@timdorr
Copy link
Member

timdorr commented Oct 5, 2016

@jide Looks like we're calling warning() incorrectly in that commit. I'll fix and push out another beta.

@jide
Copy link

jide commented Oct 5, 2016

Ok thanx !

@timdorr
Copy link
Member

timdorr commented Oct 5, 2016

OK, pushed beta.3 to remove that check for now.

clbn added a commit to clbn/freefeed-gamma that referenced this pull request Dec 4, 2016
On deleting comments, the app might want to re-render already removed
PostComment before updating PostComments (which would prevent that
by removing that comment ID from the list in the first place).

Actually, it will be fixed by migrating to react-redux@5, since that
version will be forcing the order of components' store subscriptions to
trigger top-down (see
reduxjs/react-redux#416 (comment)
plus next three comments). However, we cannot just sit and wait for
stable react-redux@5, right?

N.B. It's part of [components-rewiring], since the issue with race
condition was introduced by PostComment rewiring (connecting directly
to the store) in one of previous changes.
return false
}
for (let key in b) {
if (hasOwn.call(b, key)) countB++
Copy link

@jcready jcready Dec 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could exit before completely iterating through all the b keys by checking if countA is less than countB in each iteration:

for (let key in b) {
  if (hasOwn.call(b, key) && countA < ++countB) return false
}

return true

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A PR to change that out would be appreciated!

@natevw
Copy link

natevw commented Jan 27, 2017

Looks like this has been released. Updating to 5.0.4 fixed some seemingly-bizarre bugs (caused by a previously-incorrect assumption that props were reliably passed from the top down), and haven't noticed any regressions. Thanks for making this happen!

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

Successfully merging this pull request may close these issues.