-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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
React 16 experiment: rewrite React-Redux to use new context API #898
Conversation
Also, while I haven't tried this yet: I'm seriously considering the idea of leveraging the The idea would be to have I briefly played with the "minimal perfect hashing" idea in a sandbox a couple days ago, and it seemed that I could get string keys to consistently map to the same output bits, so that seems like it'd be feasible too. |
So the whole need to subscribe every component was in case an intermediate component implements shouldComponentUpdate() and returns false. |
Other side note: it appears that (Existing open issue: facebook/react-devtools#604 ) |
I'd check out my PR on React Router: remix-run/react-router#5908 Some things of note that I'd recommend doing:
|
I don't think I can meaningfully rebase this on top of 856 - some of the changes I made directly conflict with the ones in 856. 856 is still trying to directly subscribe to the store in I don't think I can maintain the old context API in here either, because |
Are you going to export context adapters? Someone could need the direct access to the “current” store, or reproviding it(and not only me). About hashing in areStatesEqual - first you need to “perfect hash” the changes, next you can use the “change masks” from connected components to early reject the changes. As a early rejection itself - I’ve already skipped idea in my beautiful-react-redux, as long I have that proxy underneath. Sounds promising. |
Looks awesome. @markerikson I had a hunch you were gonna work on this when I saw your tweets. I think this is a really good first draft that does a good job preserving the old stuff (i.e. for backwards compatibility), but I think if we were able to start fresh and if React were still to require so many little workarounds to beam global state efficiently to individual components, the new Context API has missed the mark.
I wonder what the start fresh approach would look like, if the primary goal was to harness new APIs as much as possible... ...after about an hour of tinkering, I've come up with the following basic format, which seems congruent with how the new context api is best put to use. Additional functionality can be brought back once the basic structure emerges. Hopefully somehow this time around thanks to the new context API + export default function connectAdvanced(selectorFactory, options) {
return function wrapWithConnect(WrappedComponent) {
class Inner extends Component {
constructor(props) {
super(props)
this.selector = selectorFactory(props.dispatch, options)
}
static getDerivedStateFromProps(nextProps, prevState) {
const { dispatch, state, ...props } = nextProps
return this.selector(state, props, prevState) // returns `null` if no changes; `prevState` (i.e. previous props to our wrapped component) used for comparisons; no need to make "stateful" ourselves.
}
addExtraProps(props) {/* the usual */}
render() {
return createElement(WrappedComponent, this.addExtraProps(this.state)) // <-- feels right
}
}
function Connect(props) {
return (
<ReactReduxContext.Consumer>
{({ dispatch, state }) => <Inner dispatch={dispatch} state={state} {...props} />}
</ReactReduxContext.Consumer>
)
}
Connect.WrappedComponent = WrappedComponent
return hoistStatics(Connect, WrappedComponent)
}
}
static contextConsumers = [ReactReduxContext.Consumer]
static getDerivedStateFromProps(nextProps, nextContexts, prevState) { That would eliminate the need for 3 components, which was less than ideal. And the official way to get access to contexts would in fact be in ps. I wonder how this compares perf-wise to @jimbolla 's major optimizations a year and a half ago. |
Here's the hypothetical/additional context API in action: export default function connectAdvanced(selectorFactory, options) {
return function wrapWithConnect(WrappedComponent) {
class Connect extends Component {
static contextConsumers = [ReactReduxContext.Consumer]
static getDerivedStateFromProps(nextProps, [context], prevState) { // perhaps 3 arg signature is only used when `static contextConsumers` is present
const { dispatch, state } = context
const selector = selectorFactory(dispatch, options) // wouldn't have to do this if we could get `dispatch` in constructor -- otherwise we can cache it on component state
return selector(state, nextProps, prevState)
}
addExtraProps(props) {/* the usual */}
render() {
return createElement(WrappedComponent, this.addExtraProps(this.state))
}
}
Connect.WrappedComponent = WrappedComponent
return hoistStatics(Connect, WrappedComponent)
}
} Ideally this task is as simple as that, minus things like HMR, etc. I never really thought it made sense that there was only a render props API for context--aside from clarity of "messaging"--when a new static properties API could also coexist with the old one, provided this isn't a huge implementation hurdle in React internals. At least on the consumer side this seems like a must. |
This is great |
@faceyspacey : yeah, I agree that an approach that requires 3 components is not preferable. As it is, I'm already annoyed at all the extra I'm definitely curious about the performance aspects. Since we're no longer subscribing in every component, there's certainly fewer actual subscribers to the store. I haven't implemented any perf optimizations yet other than shuffling around the existing store.subscribe( () => {
const storeState = store.getState();
if(storeState !== this.state.storeState) {
this.setState({storeState});
}
}); That would avoid even triggering the consumers if the state hasn't changed. |
Btw: so why the context format should be changed? Why it could not hold the “old” store and let components “subscribe” in an old way? |
@theKashey we have to jump through considerable hoops to make sure subscriptions cascade efficiently. In older versions of react-redux performance was a major issue as a tree would have many unnecessary re-renders as connected parents re-rendered connected children multiple times. So perf isn’t just about doing it outside of React—because if our work doesn’t parallel the React’s rendering algorithm, we will in fact trigger more renders. So similarly we would hope that the new context api finds the absolute most performant and intelligent algorithm to re-render all children using the same context the least number of times, among other optimizations we can’t do outside of React. By relying on React to perform this task, we can insure this stays performant as React evolves, while being able to keep this library minimal and easy to reason about. |
@theKashey : because the main concern here is not actually performance, but rather ensuring that the entire React component tree consistently sees the same "current Redux state" as components re-render at slightly different times. In order to do that, we need to have the current state being passed down from the If you read the discussion in #890 , you'll see that Dan and Andrew have been talking about actually "reimplementing the Redux store API on top of React", in order to fully make use of React's internal scheduling behavior. I'm hoping that this approach will prove to be an acceptable alternative to that. |
@markerikson just went through all of #890 , and it really doesn't seem like the strategy to "reimplement the Redux store API on top of React" makes much sense anymore once you discovered passing down the state as
As far as suspense and "tearing"--which I assume means when different parts of the page use stale state--it shouldn't really matter if part of the page is out of sync while something loads, as when putting all state in context, it's React Async's job to make sure they use the context state that existed previously. I.e. rather than update a suspended leaf because the context it depended on updated more recently. In general, the suspense stuff seems unrelated, though I think @timdorr came up with a powerful connector concept for global state stores and React Async:
However that's basically a different Redux library, not just in regards to built-in promise support, but one that has automatic "loading" state handling. I think there's something to the idea, but no library can sweepingly make the decision for you that you should start showing spinners, i.e. suspend all connected components, or suspend a top level component containing most of your app. Redux-coupled components can check if a specified state is available, and throw if not, triggering In short, I think what we're trying to do here revolves around the new Context API + |
@theKashey the funny thing about your amazing memoize-state lib is it seems we could get built-in memoization and never have to use reselect again: class Connect extends Component {
constructor(props) {
super(props)
this.selector = memoize(selectorFactory(props.dispatch, options))
}
static getDerivedStateFromProps(nextProps, prevState) {
const { dispatch, storeState, ...props } = nextProps
return this.selector(storeState, props, prevState)
} That's how many people first looking at |
@faceyspacey : well, there are more reasons for using selectors than just performance - they're also beneficial for state shape encapsulation, and as a place to put state derivation logic. Couple other quick thoughts:
|
@markerikson , you’re right, my mistake. The one based on the "hypothetical/additional" static context api didn't use export default function connectAdvanced(selectorFactory, options) {
return function wrapWithConnect(WrappedComponent) {
class Inner extends Component {
static getDerivedStateFromProps(nextProps, prev) {
const { dispatch, storeState, ...props } = nextProps
const hasSelector = prev && prev.selector
const selector = hasSelector ? prev.selector : selectorFactory(dispatch, options)
const state = selector(storeState, props, prev)
if (!prevSelector) state.selector = selector
return state
}
addExtraProps(props) {/* the usual */}
render() {
return createElement(WrappedComponent, this.addExtraProps(this.state)) // <-- feels right
}
}
function Connect(props) {
return (
<ReactReduxContext.Consumer>
{({ dispatch, storeState }) => (
<Inner dispatch={dispatch} storeState={storeState} {...props} />
)}
</ReactReduxContext.Consumer>
)
}
Connect.WrappedComponent = WrappedComponent
return hoistStatics(Connect, WrappedComponent)
}
} So essentially, on the first run, we use the selector factory, but thereafter, we use the cached version. |
Here's a demo of the above stuff working with the Redux-First Router demo on codesandbox: https://codesandbox.io/s/64mzx7vjmz?module=%2Fsrc%2Fconnect%2Findex.js "Standard" context + You'll notice not just userland memoization has been offloaded but also some of the internal The next steps would be to take this example and bring back all the potential signatures The primary connected components to checkout are:
ps. it will be interesting to see if "tearing" is no longer an issue, which it absolutely shouldn't be, since this is idiomatic context usage. |
@faceyspacey - usually then you want to Map something - you use mapStateToProps, and it should be memoize. If you want to dispatch - mapDispatch, and it could be memoized, but usually - not. If you want to mix them - you use mergeProps. Redux + memoize-state related talks have moved to theKashey/memoize-state#3 |
@markerikson is absolutely right about "structuring" the access using selectors - cascades could "share" memorization, and just beautiful, but sometimes it is not easy to use them - for example in "adapters"
I've just tested - ES5 polyfill (ie What else - even proxies and magic stuff around in production is discussable thing, it is still possible to do another trick - use memoize state in dev mode, to double check that user have mapStateToProps properly memoized. Ie if result got changed, but should not - console.error('Please check the function') (or more complex thing - "purity check") |
@markerikson I think you could very easily extend this to support multiple stores; Instead of creating one Provider/Context, you create a map of |
@markerikson you check out the demo? What you think--you think we'll be able to get rid of a lot of what made this library so intimidating in the past? I'm also curious as to whether this eliminates the additional work regarding @gaearon 's Here's the link again: ps. didn't mean to hi-jack your PR, just seemed like the best and only place to discuss this and it was something I had been thinking about for a while. |
@markerikson re the last point on summary of changes:
For testing, couldnt we just manually pass |
@coryb08 : I'm saying that in this PR, that capability no longer exists. Based on some Twitter feedback, sounds like it's getting some active use, so I'll see if I can re-implement the capability in this PR. @faceyspacey : uh... what do you mean by "what made this library so intimidating" ? And yes, the idea behind this PR is that it would hopefully work correctly with async rendering without the tearing issues. I'm hoping to talk to the React team this week to get some feedback on the approach. |
@elisherer There are many projects where that's not necessarily true. What might be interesting is to mirror the context API and generate a Provider/connect pair. The default ones would be a singleton: import { createConnector } from 'react-redux'
const ConnectorA = createConnector()
const ConnectorB = createConnector()
import App from './App'
const ConnectedApp = ConnectorA.connect()(App)
import SubApp from './SubApp'
const ConnectedSubApp = ConnectorB.connect()(SubApp)
ReactDOM.render(
<ConnectorA.Provider store={store}>
<ConnectorB.Provider store={substore}>
<App>
<SubApp />
</App>
</ConnectorB.Provider>
</ConnectorA.Provider>
) |
@timdorr, that is exactly the case in Hypothetic example:| //before
import { reduxForm, Field } from 'redux-form';
...
<Field name="firstName"/>
...
export default reduxForm(config)(MyForm);
//after
import { reduxForm, Field } from 'redux-form';
...
<Field name="firstName" store={store} />
...
export default reduxForm(store, config)(MyForm); Creating a pair of |
Bad idea, as long (reusable/third party) component should just establish a connection to the (user defined) store, but could not obtain a reference to it (read - import '../../createStore.js'), as long isolated. |
|
||
// TODO How do we express the invariant of needing a Provider when it's used in render()? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Consumer of createContext will take on the default value when it's not under the paired Provider. So, I think just leaving this invariant in place would do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Example: https://codesandbox.io/s/p5o489mym0
I've spent the last couple months prepping for a workshop, which just wrapped up. I need a bit of down time to try to recuperate, but after that I want to come back and look at both this PR and my starter kit package, and see about pushing things along. |
Actually, the polyfill works with React 15 as well |
509: 2018-04-23のJS: Chrome 66、Redux 4.0、Svelte 2.0 r=azu a=azu * [New in Chrome 66 | Web | Google Developers](https://developers.google.com/web/updates/2018/04/nic66 "New in Chrome 66 | Web | Google Developers") * [Chrome Platform Status](https://www.chromestatus.com/features#milestone%3D66 "Chrome Platform Status") * [Chromium Blog: Chrome 66 Beta: CSS Typed Object Model, Async Clipboard API, AudioWorklet](https://blog.chromium.org/2018/03/chrome-66-beta-css-typed-object-model.html "Chromium Blog: Chrome 66 Beta: CSS Typed Object Model, Async Clipboard API, AudioWorklet") * [Svelte v2 is out!](https://svelte.technology/blog/version-2 "Svelte v2 is out!") * [Release v4.0.0 · reactjs/redux](https://github.com/reactjs/redux/releases/tag/v4.0.0 "Release v4.0.0 · reactjs/redux") * [React 16 experiment: rewrite React-Redux to use new context API by markerikson · Pull Request #898 · reactjs/react-redux](reduxjs/react-redux#898 "React 16 experiment: rewrite React-Redux to use new context API by markerikson · Pull Request #898 · reactjs/react-redux") Co-authored-by: azu <[email protected]> Co-authored-by: azu <[email protected]>
What is the status here? |
Noting this here as a scratchpad for later reference. A user on HN commented that React-Redux makes a 3D VR app on mobile devices a lot slower. Could be an interesting benchmark overall, as well as a good perf comparison overall when I manage to push this forward: https://news.ycombinator.com/item?id=17474305
|
@markerikson I ended up implementing the benchmark with screenshots of the performance profile. The performance drop after It is due to the fact that behind a call to Now using Redux or something else as a data store is totally orthogonal so |
@Kalkut : Thanks, really appreciate it! Yeah, we've always said that the act of dispatching and running the root reducer is unlikely to be the bottleneck in most apps - it's usually the process of updating the UI instead. I know that @jimbolla had some benchmark repos he worked with when he was writing React-Redux v5. We probably ought to do something about collecting some benchmarks in one place for future reference. |
FOR DISCUSSION ONLY - DO NOT MERGE
Per discussion in #890 , React-Redux will need to change to support React's upcoming async rendering behavior.
I spent this afternoon experimenting with an update of our current v5 codebase to use the new
React.createContext()
API that will be out in React 16. To my very pleasant surprise, it actually seems to be working out better than I thought it would.Summary of Changes
connectAdvanced
. Now,<Provider>
subscribes to the store, and callsthis.setState({storeState})
in the subscription callback (sorta similar to how the v4connect
implementation did things).React.createContext()
once internally to create a singleReactReduxContext
provider/consumer pair. Both<Provider>
andconnectAdvanced
use that pair. All references tocontextTypes
have been removed.connectAdvanced
wrappers now directly use the context pair, the entire Subscription concept has been removedconnectAdvanced.componentWillReceiveProps
was renamed toUNSAFE_componentWillReceiveProps
. (Note that I'm not sure if this is still needed.)renderChild
render prop callback.store
as a prop, because they don't/can't subscribe to it anyway. There could be use cases where this is still desirable in some way (particularly for testing?). We may need to investigate alternatives, such as exporting acreateReactReduxContext()
function, or possibly allowing<Provider contextProvider={myContext.Provider}>
somehow.Observations and Testing
Tests
I made a few quick edits to our unit test suite, primarily changing
ProviderMock
to point to the actualProvider
class instead. Right now we've got 25 failing unit tests, but I think that most of them are likely to no longer be relevant (such as "should unsubscribe before unmounting").Main Testing Setup
I cloned and built
facebook/react@fcc4f52
. I then usedyarn link
with the built copies ofreact
andreact-dom
inside ofbuild/node_modules
, and usedyarn link
on my local clone ofreact-redux
.I then opened up the local repo for my Project Mini-Mek sample application, and used
yarn link
to load inreact
,react-dom
, andreact-redux
.Main Testing Observations
When I start and load Project Mini-Mek, the application appears to correctly load and run as expected. I can click through the tabs, load the sample data, select pilot entries, and edit a pilot entry, with no obvious errors in the console or the UI.
If I view which components are updating and re-rendering with a combination of turning on "Highlight Updates" in the React DevTools, and adding console logging to
connectAdvanced.renderChild()
), I see only the components that I expect to re-render updating. In particular, the list items in the current HEAD of Project Mini-Mek are not currently using memoized selectors. I tried optimizing them via themakeMapState()
technique, and saw that they no longer are re-rendering on every store state update.Other Testing Observations
Before modifying React-Redux v5, I threw together a quick tiny version of the "Provider subscribes" approach separately, and whipped up a tiny Redux counter example in a CRA app. I then compared behavior between dispatching 1
"INCREMENT"
action and dispatching 5 `"INCREMENT" actions in a row, in both React click event handlers and non-React DOM element click handlers.I observed that dispatching 5 actions in a row in a React event handler only caused the Provider and the connected component to both re-render once, as React was batching up its
setState()
calls. However, dispatching five times in a row from a DOM element click handler caused five separateProvider -> connect -> component
update cycles, as thesetState()
calls were not batched.Finally, I tried adding a test key to the Project Mini-Mek Redux store, had a component subscribe to that key, and then created a second Redux store instance with the same key but a different value. I then had my app render a nested
<Provider store={secondStore}>
, and had a test component dispatch an action. The nested test component correctly picked up the data from the second store, and the dispatched action updated the second store.Conclusion
Given that I've only hacked on this for the last few hours, this actually appears to be working out way better than I had anticipated. I'm sure there's all kinds of edge cases I haven't run into yet, but this looks like a viable first attempt at a React-Redux v6 that would be compatible with React 16.3+.
@gaearon, @acdlite, @bvaughn , @timdorr, @jimbolla : so, how many ways is this broken right now? :)