diff --git a/docs/api.md b/docs/api.md
index cdd40b4c4..590d4c8cd 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -52,7 +52,9 @@ It does not modify the component class passed to it; instead, it *returns* a new
#### Arguments
-* [`mapStateToProps(state, [ownProps]): stateProps`] \(*Function*): If this argument is specified, the new component will subscribe to Redux store updates. This means that any time the store is updated, `mapStateToProps` will be called. The results of `mapStateToProps` must be a plain object*, which will be merged into the component’s props. If you don't want to subscribe to store updates, pass `null` or `undefined` in place of `mapStateToProps`. If `ownProps` is specified as a second argument, its value will be the props passed to your component, and `mapStateToProps` will be additionally re-invoked whenever the component receives new props (e.g. if props received from a parent component have shallowly changed, and you use the ownProps argument, mapStateToProps is re-evaluated).
+* [`mapStateToProps(state, [ownProps]): stateProps`] \(*Function* or *Object*): If this argument is specified, the new component will subscribe to Redux store updates. This means that any time the store is updated, `mapStateToProps` will be called. The results of `mapStateToProps` must be a plain object*, which will be merged into the component’s props. If you don't want to subscribe to store updates, pass `null` or `undefined` in place of `mapStateToProps`. If `ownProps` is specified as a second argument, its value will be the props passed to your component, and `mapStateToProps` will be additionally re-invoked whenever the component receives new props (e.g. if props received from a parent component have shallowly changed, and you use the ownProps argument, mapStateToProps is re-evaluated).
+
+If an object is passed, each function inside it is assumed to be a Redux selector.
>Note: in advanced scenarios where you need more control over the rendering performance, `mapStateToProps()` can also return a function. In this case, *that* function will be used as `mapStateToProps()` for a particular component instance. This allows you to do per-instance memoization. You can refer to [#279](https://github.com/reactjs/react-redux/pull/279) and the tests it adds for more details. Most apps never need this.
diff --git a/src/connect/mapStateToProps.js b/src/connect/mapStateToProps.js
index 039291b0a..6ae957f88 100644
--- a/src/connect/mapStateToProps.js
+++ b/src/connect/mapStateToProps.js
@@ -6,6 +6,31 @@ export function whenMapStateToPropsIsFunction(mapStateToProps) {
: undefined
}
+function getSelectorFromObject(objectSelectors) {
+ return function (state) {
+ const result = {};
+ Object.keys(objectSelectors).forEach(function (key) {
+ result[key] = objectSelectors[key](state);
+ });
+ return result;
+ };
+}
+
+function isValidmapStateToPropsObj(mapStateToProps) {
+ return typeof mapStateToProps === 'object' && Object
+ .keys(mapStateToProps)
+ .map(function (key) { return mapStateToProps[key] })
+ .every(function (val) { return typeof val === 'function'; });
+}
+
+export function whenMapStateToPropsIsObject(mapStateToProps) {
+ return (isValidmapStateToPropsObj(mapStateToProps)) ?
+ wrapMapToPropsFunc(
+ getSelectorFromObject(mapStateToProps), 'mapStateToProps'
+ ) :
+ undefined
+}
+
export function whenMapStateToPropsIsMissing(mapStateToProps) {
return (!mapStateToProps)
? wrapMapToPropsConstant(() => ({}))
@@ -14,5 +39,6 @@ export function whenMapStateToPropsIsMissing(mapStateToProps) {
export default [
whenMapStateToPropsIsFunction,
+ whenMapStateToPropsIsObject,
whenMapStateToPropsIsMissing
]
diff --git a/test/components/connect.spec.js b/test/components/connect.spec.js
index 094f4d697..605ef69d6 100644
--- a/test/components/connect.spec.js
+++ b/test/components/connect.spec.js
@@ -7,6 +7,9 @@ import TestUtils from 'react-addons-test-utils'
import { createStore } from 'redux'
import { connect } from '../../src/index'
+const withMapStateToProps = (...args) => test =>
+ args.forEach((mapStateToProps) => { test(mapStateToProps) });
+
describe('React', () => {
describe('connect', () => {
class Passthrough extends Component {
@@ -78,6 +81,10 @@ describe('React', () => {
expect(container.context.store).toBe(store)
})
+ withMapStateToProps(
+ ({ foo, baz }) => ({ foo, baz }),
+ ({ foo: state => state.foo, baz: state => state.baz })
+ )(mapStateToProps =>
it('should pass state and props to the given component', () => {
const store = createStore(() => ({
foo: 'bar',
@@ -85,7 +92,7 @@ describe('React', () => {
hello: 'world'
}))
- @connect(({ foo, baz }) => ({ foo, baz }))
+ @connect(mapStateToProps)
class Container extends Component {
render() {
return
@@ -105,12 +112,16 @@ describe('React', () => {
expect(() =>
TestUtils.findRenderedComponentWithType(container, Container)
).toNotThrow()
- })
+ }))
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should subscribe class components to the store changes', () => {
const store = createStore(stringBuilder)
- @connect(state => ({ string: state }) )
+ @connect(mapStateToProps)
class Container extends Component {
render() {
return
@@ -129,13 +140,17 @@ describe('React', () => {
expect(stub.props.string).toBe('a')
store.dispatch({ type: 'APPEND', body: 'b' })
expect(stub.props.string).toBe('ab')
- })
+ }))
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should subscribe pure function components to the store changes', () => {
const store = createStore(stringBuilder)
let Container = connect(
- state => ({ string: state })
+ mapStateToProps
)(function Container(props) {
return
})
@@ -155,13 +170,17 @@ describe('React', () => {
expect(stub.props.string).toBe('a')
store.dispatch({ type: 'APPEND', body: 'b' })
expect(stub.props.string).toBe('ab')
- })
+ }))
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should retain the store\'s context', () => {
const store = new ContextBoundStore(stringBuilder)
let Container = connect(
- state => ({ string: state })
+ mapStateToProps
)(function Container(props) {
return
})
@@ -179,12 +198,16 @@ describe('React', () => {
expect(stub.props.string).toBe('')
store.dispatch({ type: 'APPEND', body: 'a' })
expect(stub.props.string).toBe('a')
- })
+ }))
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should handle dispatches before componentDidMount', () => {
const store = createStore(stringBuilder)
- @connect(state => ({ string: state }) )
+ @connect(mapStateToProps)
class Container extends Component {
componentWillMount() {
store.dispatch({ type: 'APPEND', body: 'a' })
@@ -203,14 +226,18 @@ describe('React', () => {
const stub = TestUtils.findRenderedComponentWithType(tree, Passthrough)
expect(stub.props.string).toBe('a')
- })
+ }))
+ withMapStateToProps(
+ state => state,
+ { foo: state => state.foo }
+ )(mapStateToProps =>
it('should handle additional prop changes in addition to slice', () => {
const store = createStore(() => ({
foo: 'bar'
}))
- @connect(state => state)
+ @connect(mapStateToProps)
class ConnectContainer extends Component {
render() {
return (
@@ -248,12 +275,16 @@ describe('React', () => {
const stub = TestUtils.findRenderedComponentWithType(container, Passthrough)
expect(stub.props.foo).toEqual('bar')
expect(stub.props.pass).toEqual('through')
- })
+ }))
+ withMapStateToProps(
+ state => state,
+ {}
+ )(mapStateToProps =>
it('should handle unexpected prop changes with forceUpdate()', () => {
const store = createStore(() => ({}))
- @connect(state => state)
+ @connect(mapStateToProps)
class ConnectContainer extends Component {
render() {
return (
@@ -286,14 +317,18 @@ describe('React', () => {
const container = TestUtils.renderIntoDocument()
const stub = TestUtils.findRenderedComponentWithType(container, Passthrough)
expect(stub.props.bar).toEqual('foo')
- })
+ }))
+ withMapStateToProps(
+ () => ({}),
+ {}
+ )(mapStateToProps =>
it('should remove undefined props', () => {
const store = createStore(() => ({}))
let props = { x: true }
let container
- @connect(() => ({}), () => ({}))
+ @connect(mapStateToProps, () => ({}))
class ConnectContainer extends Component {
render() {
return (
@@ -329,14 +364,18 @@ describe('React', () => {
expect(propsBefore.x).toEqual(true)
expect('x' in propsAfter).toEqual(false, 'x prop must be removed')
- })
+ }))
+ withMapStateToProps(
+ () => ({}),
+ {}
+ )(mapStateToProps =>
it('should remove undefined props without mapDispatch', () => {
const store = createStore(() => ({}))
let props = { x: true }
let container
- @connect(() => ({}))
+ @connect(mapStateToProps)
class ConnectContainer extends Component {
render() {
return (
@@ -372,14 +411,18 @@ describe('React', () => {
expect(propsBefore.x).toEqual(true)
expect('x' in propsAfter).toEqual(false, 'x prop must be removed')
- })
+ }))
+ withMapStateToProps(
+ state => state,
+ { foo: state => state.foo }
+ )(mapStateToProps =>
it('should ignore deep mutations in props', () => {
const store = createStore(() => ({
foo: 'bar'
}))
- @connect(state => state)
+ @connect(mapStateToProps)
class ConnectContainer extends Component {
render() {
return (
@@ -420,8 +463,12 @@ describe('React', () => {
const stub = TestUtils.findRenderedComponentWithType(container, Passthrough)
expect(stub.props.foo).toEqual('bar')
expect(stub.props.pass).toEqual('')
- })
+ }))
+ withMapStateToProps(
+ state => ({ stateThing: state }),
+ { stateThing: state => state }
+ )(mapStateToProps =>
it('should allow for merge to incorporate state and prop changes', () => {
const store = createStore(stringBuilder)
@@ -433,7 +480,7 @@ describe('React', () => {
}
@connect(
- state => ({ stateThing: state }),
+ mapStateToProps,
dispatch => ({
doSomething: (whatever) => dispatch(doSomething(whatever))
}),
@@ -477,15 +524,19 @@ describe('React', () => {
tree.setState({ extra: 'Z' })
stub.props.mergedDoSomething('c')
expect(stub.props.stateThing).toBe('HELLO azbzcZ')
- })
+ }))
+ withMapStateToProps(
+ state => state,
+ { foo: state => state.foo }
+ )(mapStateToProps =>
it('should merge actionProps into WrappedComponent', () => {
const store = createStore(() => ({
foo: 'bar'
}))
@connect(
- state => state,
+ mapStateToProps,
dispatch => ({ dispatch })
)
class Container extends Component {
@@ -507,7 +558,7 @@ describe('React', () => {
).toNotThrow()
const decorated = TestUtils.findRenderedComponentWithType(container, Container)
expect(decorated.isSubscribed()).toBe(true)
- })
+ }))
it('should not invoke mapState when props change if it only has one argument', () => {
const store = createStore(stringBuilder)
@@ -839,6 +890,10 @@ describe('React', () => {
runCheck(false, false, false)
})
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should unsubscribe before unmounting', () => {
const store = createStore(stringBuilder)
const subscribe = store.subscribe
@@ -854,7 +909,7 @@ describe('React', () => {
}
@connect(
- state => ({ string: state }),
+ mapStateToProps,
dispatch => ({ dispatch })
)
class Container extends Component {
@@ -874,7 +929,7 @@ describe('React', () => {
expect(spy.calls.length).toBe(0)
ReactDOM.unmountComponentAtNode(div)
expect(spy.calls.length).toBe(1)
- })
+ }))
it('should not attempt to set state after unmounting', () => {
const store = createStore(stringBuilder)
@@ -909,10 +964,14 @@ describe('React', () => {
expect(mapStateToPropsCalls).toBe(1)
})
+ withMapStateToProps(
+ (state) => ({ hide: state === 'AB' }),
+ { hide: state => state === 'AB' }
+ )(mapStateToProps =>
it('should not attempt to notify unmounted child of state change', () => {
const store = createStore(stringBuilder)
- @connect((state) => ({ hide: state === 'AB' }))
+ @connect(mapStateToProps)
class App extends Component {
render() {
return this.props.hide ? null :
@@ -953,7 +1012,7 @@ describe('React', () => {
} finally {
ReactDOM.unmountComponentAtNode(div)
}
- })
+ }))
it('should not attempt to set state after unmounting nested components', () => {
const store = createStore(() => ({}))
@@ -1070,6 +1129,10 @@ describe('React', () => {
expect(mapStateToPropsCalls).toBe(1)
})
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should shallowly compare the selected state to prevent unnecessary updates', () => {
const store = createStore(stringBuilder)
const spy = expect.createSpy(() => ({}))
@@ -1079,7 +1142,7 @@ describe('React', () => {
}
@connect(
- state => ({ string: state }),
+ mapStateToProps,
dispatch => ({ dispatch })
)
class Container extends Component {
@@ -1103,8 +1166,12 @@ describe('React', () => {
expect(spy.calls.length).toBe(3)
store.dispatch({ type: 'APPEND', body: '' })
expect(spy.calls.length).toBe(3)
- })
+ }))
+ withMapStateToProps(
+ state => ({ string: state }),
+ { string: state => state }
+ )(mapStateToProps =>
it('should shallowly compare the merged state to prevent unnecessary updates', () => {
const store = createStore(stringBuilder)
const spy = expect.createSpy(() => ({}))
@@ -1114,7 +1181,7 @@ describe('React', () => {
}
@connect(
- state => ({ string: state }),
+ mapStateToProps,
dispatch => ({ dispatch }),
(stateProps, dispatchProps, parentProps) => ({
...dispatchProps,
@@ -1191,7 +1258,7 @@ describe('React', () => {
expect(spy.calls.length).toBe(5)
expect(stub.props.string).toBe('a')
expect(stub.props.passVal).toBe('otherval')
- })
+ }))
it('should throw an error if a component is not passed to the function returned by connect', () => {
expect(connect()).toThrow(