From dcaee67e08856742e52c1f84a05a3c7cec4c1fa8 Mon Sep 17 00:00:00 2001 From: Ivan Starkov Date: Thu, 6 Jul 2017 22:30:23 +0300 Subject: [PATCH] withStateHandlers (new withState) (#421) * Move mapValues into utils * Add withStateHandlers --- docs/API.md | 47 +++++ .../recompose/__tests__/treeshake-test.js | 1 + .../__tests__/withStateHandlers-test.js | 193 ++++++++++++++++++ src/packages/recompose/index.js | 1 + src/packages/recompose/utils/mapValues.js | 13 ++ src/packages/recompose/withHandlers.js | 13 +- src/packages/recompose/withStateHandlers.js | 45 ++++ 7 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 src/packages/recompose/__tests__/withStateHandlers-test.js create mode 100644 src/packages/recompose/utils/mapValues.js create mode 100644 src/packages/recompose/withStateHandlers.js diff --git a/docs/API.md b/docs/API.md index 9f1e68f4..0c6ef40f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -43,6 +43,7 @@ const PureComponent = pure(BaseComponent) + [`renameProps()`](#renameprops) + [`flattenProp()`](#flattenprop) + [`withState()`](#withstate) + + [`withStateHandlers()`](#withStateHandlers) + [`withReducer()`](#withreducer) + [`branch()`](#branch) + [`renderComponent()`](#rendercomponent) @@ -275,6 +276,52 @@ Both forms accept an optional second parameter, a callback function that will be An initial state value is required. It can be either the state value itself, or a function that returns an initial state given the initial props. +### `withStateHandlers()` + +```js +withStateHandlers( + initialState: Object | (props: Object) => any, + stateUpdaters: { + [key: string]: (state:Object, props:Object) => (...payload: any[]) => Object + } +) + +``` + +Passes state object properties and immutable updater functions +in a form of `(...payload: any[]) => Object` to the base component. + +Every state updater function accepts state, props and payload and must return a new state or undefined. +Returning undefined does not cause a component rerender. + +Example: + +```js + const Counter = withStateHandlers( + ({ initialCounter = 0 }) => ({ + counter: initialCounter, + }), + { + incrementOn: ({ counter }) => (value) => ({ + counter: counter + value, + }), + decrementOn: ({ counter }) => (value) => ({ + counter: counter - value, + }), + resetCounter: (_, { initialCounter = 0 }) => () => ({ + counter: initialCounter, + }), + } + )( + ({ counter, incrementOn, decrementOn, resetCounter }) => +
+ + + +
+ ) +``` + ### `withReducer()` ```js diff --git a/src/packages/recompose/__tests__/treeshake-test.js b/src/packages/recompose/__tests__/treeshake-test.js index 1a96496c..40c7cfb4 100644 --- a/src/packages/recompose/__tests__/treeshake-test.js +++ b/src/packages/recompose/__tests__/treeshake-test.js @@ -11,6 +11,7 @@ const list = [ 'renameProps', 'flattenProp', 'withState', + 'withStateHandlers', 'withReducer', 'branch', 'renderComponent', diff --git a/src/packages/recompose/__tests__/withStateHandlers-test.js b/src/packages/recompose/__tests__/withStateHandlers-test.js new file mode 100644 index 00000000..13d4cf60 --- /dev/null +++ b/src/packages/recompose/__tests__/withStateHandlers-test.js @@ -0,0 +1,193 @@ +import React from 'react' +import { mount } from 'enzyme' +import sinon from 'sinon' + +import { compose, withStateHandlers } from '../' + +test('withStateHandlers adds a stateful value and a function for updating it', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers( + { counter: 0 }, + { + updateCounter: ({ counter }) => increment => ({ + counter: counter + increment, + }), + } + )(component) + expect(Counter.displayName).toBe('withStateHandlers(component)') + + mount() + const { updateCounter } = component.firstCall.args[0] + + expect(component.lastCall.args[0].counter).toBe(0) + expect(component.lastCall.args[0].pass).toBe('through') + + updateCounter(9) + expect(component.lastCall.args[0].counter).toBe(9) + updateCounter(1) + updateCounter(10) + + expect(component.lastCall.args[0].counter).toBe(20) + expect(component.lastCall.args[0].pass).toBe('through') +}) + +test('withStateHandlers accepts initialState as function of props', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers( + ({ initialCounter }) => ({ + counter: initialCounter, + }), + { + updateCounter: ({ counter }) => increment => ({ + counter: counter + increment, + }), + } + )(component) + + const initialCounter = 101 + + mount() + expect(component.lastCall.args[0].counter).toBe(initialCounter) +}) + +test('withStateHandlers initial state must be function or object or null or undefined', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers(1, {})(component) + // React throws an error + expect(() => mount()).toThrow() +}) + +test('withStateHandlers have access to props', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers( + ({ initialCounter }) => ({ + counter: initialCounter, + }), + { + increment: ({ counter }, { incrementValue }) => () => ({ + counter: counter + incrementValue, + }), + } + )(component) + + const initialCounter = 101 + const incrementValue = 37 + + mount( + + ) + + const { increment } = component.firstCall.args[0] + + increment() + expect(component.lastCall.args[0].counter).toBe( + initialCounter + incrementValue + ) +}) + +test('withStateHandlers passes immutable state updaters', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers( + ({ initialCounter }) => ({ + counter: initialCounter, + }), + { + increment: ({ counter }, { incrementValue }) => () => ({ + counter: counter + incrementValue, + }), + } + )(component) + + const initialCounter = 101 + const incrementValue = 37 + + mount( + + ) + + const { increment } = component.firstCall.args[0] + + increment() + expect(component.lastCall.args[0].counter).toBe( + initialCounter + incrementValue + ) +}) + +test('withStateHandlers does not rerender if state updater returns undefined', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = withStateHandlers( + ({ initialCounter }) => ({ + counter: initialCounter, + }), + { + updateCounter: ({ counter }) => increment => + increment === 0 + ? undefined + : { + counter: counter + increment, + }, + } + )(component) + + const initialCounter = 101 + + mount() + expect(component.callCount).toBe(1) + + const { updateCounter } = component.firstCall.args[0] + + updateCounter(1) + expect(component.callCount).toBe(2) + + updateCounter(0) + expect(component.callCount).toBe(2) +}) + +test('withStateHandlers rerenders if parent props changed', () => { + const component = sinon.spy(() => null) + component.displayName = 'component' + + const Counter = compose( + withStateHandlers( + ({ initialCounter }) => ({ + counter: initialCounter, + }), + { + increment: ({ counter }) => incrementValue => ({ + counter: counter + incrementValue, + }), + } + ), + withStateHandlers( + { incrementValue: 1 }, + { + // updates parent state and return undefined + updateParentIncrement: ({ incrementValue }, { increment }) => () => { + increment(incrementValue) + return undefined + }, + } + ) + )(component) + + const initialCounter = 101 + + mount() + + const { updateParentIncrement } = component.firstCall.args[0] + + updateParentIncrement() + expect(component.lastCall.args[0].counter).toBe(initialCounter + 1) +}) diff --git a/src/packages/recompose/index.js b/src/packages/recompose/index.js index ace3abae..39c707c0 100644 --- a/src/packages/recompose/index.js +++ b/src/packages/recompose/index.js @@ -8,6 +8,7 @@ export { default as renameProp } from './renameProp' export { default as renameProps } from './renameProps' export { default as flattenProp } from './flattenProp' export { default as withState } from './withState' +export { default as withStateHandlers } from './withStateHandlers' export { default as withReducer } from './withReducer' export { default as branch } from './branch' export { default as renderComponent } from './renderComponent' diff --git a/src/packages/recompose/utils/mapValues.js b/src/packages/recompose/utils/mapValues.js new file mode 100644 index 00000000..2866b22f --- /dev/null +++ b/src/packages/recompose/utils/mapValues.js @@ -0,0 +1,13 @@ +const mapValues = (obj, func) => { + const result = {} + /* eslint-disable no-restricted-syntax */ + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + result[key] = func(obj[key], key) + } + } + /* eslint-enable no-restricted-syntax */ + return result +} + +export default mapValues diff --git a/src/packages/recompose/withHandlers.js b/src/packages/recompose/withHandlers.js index b7398093..427461d3 100644 --- a/src/packages/recompose/withHandlers.js +++ b/src/packages/recompose/withHandlers.js @@ -3,18 +3,7 @@ import { Component } from 'react' import createEagerFactory from './createEagerFactory' import setDisplayName from './setDisplayName' import wrapDisplayName from './wrapDisplayName' - -const mapValues = (obj, func) => { - const result = {} - /* eslint-disable no-restricted-syntax */ - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - result[key] = func(obj[key], key) - } - } - /* eslint-enable no-restricted-syntax */ - return result -} +import mapValues from './utils/mapValues' const withHandlers = handlers => BaseComponent => { const factory = createEagerFactory(BaseComponent) diff --git a/src/packages/recompose/withStateHandlers.js b/src/packages/recompose/withStateHandlers.js new file mode 100644 index 00000000..6fed876f --- /dev/null +++ b/src/packages/recompose/withStateHandlers.js @@ -0,0 +1,45 @@ +import { Component } from 'react' +import setDisplayName from './setDisplayName' +import wrapDisplayName from './wrapDisplayName' +import createEagerFactory from './createEagerFactory' +import shallowEqual from './shallowEqual' +import mapValues from './utils/mapValues' + +const withStateHandlers = (initialState, stateUpdaters) => BaseComponent => { + const factory = createEagerFactory(BaseComponent) + + class WithStateHandlers extends Component { + state = typeof initialState === 'function' + ? initialState(this.props) + : initialState + + stateUpdaters = mapValues(stateUpdaters, handler => (...args) => + this.setState((state, props) => handler(state, props)(...args)) + ) + + shouldComponentUpdate(nextProps, nextState) { + const propsChanged = nextProps !== this.props + // the idea is to skip render if stateUpdater handler return undefined + // this allows to create no state update handlers with access to state and props + const stateChanged = !shallowEqual(nextState, this.state) + return propsChanged || stateChanged + } + + render() { + return factory({ + ...this.props, + ...this.state, + ...this.stateUpdaters, + }) + } + } + + if (process.env.NODE_ENV !== 'production') { + return setDisplayName(wrapDisplayName(BaseComponent, 'withStateHandlers'))( + WithStateHandlers + ) + } + return WithStateHandlers +} + +export default withStateHandlers