reduxjs / react-redux

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

mapStateToProps() is not orthogonal to reducers #226

Closed planetcohen closed 8 years ago

planetcohen commented 8 years ago

One of the really nice results of reducer composition is that it is possible to create reducers that only need to know about their part of the global state tree. This is unfortunately not currently the case with mapStateToProps, which is handed the complete global state tree. A consequence of this is that when an app gets larger, and you re-organize the state tree, you need to touch every instance of mapStateToProps.

For example, suppose that I start out with an app that deals with articles. I have an ArticlesListView that I connect() to the state like so:

const state = { /* stuff about articles */ };
const reduceArticles = (state, action) => { /* handle action appropriately */ }
const mapStateToProps = (state) => { /* do stuff with articles */ }
const ConnectedArticlesListView = connect(mapStateToProps)(ArticlesListView);

Now, my app grows and I want to introduce functionality related to users. No problem, I simply update my global state like so:

const articles = { /* stuff about articles */ };
const users = { /* stuff about users */ };
const state = { articles, users }

Because of how reducer composition works, I don't need to touch my reduceArticles implementation. But I do need to update my mapStateToProps, since the state it operates on has been moved to the articles sub-key of the global state. That's what I mean when I stated that mapStateToProps is not orthogonal to reducers.

There is a work around with the current implementation of connect, which is to use the store prop on the connected component, like so:

const articlesStore = (store) => {
  const getState = () => store.getState().articles
  return { ...store, getState }
}
<ConnectedArticlesListView store={articlesStore(store)} />

Now, my mapStateToProps function for articles is handed only the articles branch of the global state tree and doesn't need to be updated.

You can make this generic, for any attribute of the global state like so:

const scopedStore = (store, scope) => {
  const getState = () => store.getState()[scope]
  return { ...store, getState }
}
<ConnectedArticlesListView store={scopedStore(store, 'articles')} />
<ConnectedUsersListView store={scopedStore(store, 'users')} />

This is all possible with the current implementation of connect, and it's what I'm doing in my apps. But it's not obvious and it requires a bunch of plumbing on my part.

Since I believe this will be such a common pattern, I recommend that we make this plumbing easy by adding a scope prop to the connected component like so:

<ConnectedArticlesListView scope='articles' />
<ConnectedUsersListView scope='users' />

Finally, as an app gets larger, it is likely that state will get further nested. This could be supported by allowing dot-notation on the scope selector like so:

<ConnectedArticlesListView scope='publications.articles' />

The corresponding scopedStore function would now look like this:

const scopedStore = (store, scope) => {
  const select = (state, selector) => state[selector]
  const getState = () => scope.split('.').reduce(select, store.getState())
  return { ...store,  getState };
}

If you guys think this makes sense, I'd be happy to contribute a PR for this.

tudorilisoi commented 8 years ago

What if I need both users and articles form a larger store, i.e 2 or more scopes, yet not the whole thing? This may well be the case with some components, IMHO

planetcohen commented 8 years ago

Perhaps articles and users is not the best example; think articles and products instead. I think it's most likely that you'll have views that display articles and separate views that display products.

bunkat commented 8 years ago

This particular pattern wouldn't be very useful in my applications. I have higher level connected components that grab all the state that is needed to display a portion of a page that then hand 'scoped' data to dumb components to actually do the rendering. Pages need user state (preferences), ui state, interconnected entity state, etc in order to render anything.

I use reselect in order to hide the actual shape of the state store from the connected components. My components have no idea what the store actually looks like at all. For example, I have a set of selectors that handle session state.

import { createSelector } from 'reselect';

const sessionSelector = state => state.get('session');

export const currentSessionSelector = createSelector(
  sessionSelector,
  (session) => session
);

Then my components can get the current session state like this:

function selectorsToProps(state) {
  return {
    session: sessionSelectors.currentSessionSelector(state),
  };
}

export default connect(selectorsToProps)(LoginPage);

Here the component has no idea where the session state is stored, it just knows how to get it. Now I can move it around whenever I want, fix up one selector (the const sessionSelector) and everything else just works. The other nice thing about reselect is that it memoizes calls so that you can have logic in the selectors that is also hidden from the components (such as filters/sorting) without slowing things down.

gaearon commented 8 years ago

Selectors is our approach to this. Write selectors alongside your reducers, and you never need to know the exact state path. You don't have to use reselect (although it's good for perf). Just group selectors with reducers and require them up the reducer tree so components talk to root selectors. Example