Skip to content
This repository has been archived by the owner on Sep 10, 2022. It is now read-only.

Commit

Permalink
withStateHandlers (new withState) (#421)
Browse files Browse the repository at this point in the history
* Move mapValues into utils

* Add withStateHandlers
  • Loading branch information
istarkov authored Jul 6, 2017
1 parent 00f4316 commit dcaee67
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 12 deletions.
47 changes: 47 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const PureComponent = pure(BaseComponent)
+ [`renameProps()`](#renameprops)
+ [`flattenProp()`](#flattenprop)
+ [`withState()`](#withstate)
+ [`withStateHandlers()`](#withStateHandlers)
+ [`withReducer()`](#withreducer)
+ [`branch()`](#branch)
+ [`renderComponent()`](#rendercomponent)
Expand Down Expand Up @@ -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 }) =>
<div>
<Button onClick={() => incrementOn(2)}>Inc</Button>
<Button onClick={() => decrementOn(3)}>Dec</Button>
<Button onClick={resetCounter}>Dec</Button>
</div>
)
```

### `withReducer()`

```js
Expand Down
1 change: 1 addition & 0 deletions src/packages/recompose/__tests__/treeshake-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const list = [
'renameProps',
'flattenProp',
'withState',
'withStateHandlers',
'withReducer',
'branch',
'renderComponent',
Expand Down
193 changes: 193 additions & 0 deletions src/packages/recompose/__tests__/withStateHandlers-test.js
Original file line number Diff line number Diff line change
@@ -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(<Counter pass="through" />)
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(<Counter initialCounter={initialCounter} />)
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(<Counter />)).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(
<Counter initialCounter={initialCounter} incrementValue={incrementValue} />
)

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(
<Counter initialCounter={initialCounter} incrementValue={incrementValue} />
)

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(<Counter initialCounter={initialCounter} />)
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(<Counter initialCounter={initialCounter} />)

const { updateParentIncrement } = component.firstCall.args[0]

updateParentIncrement()
expect(component.lastCall.args[0].counter).toBe(initialCounter + 1)
})
1 change: 1 addition & 0 deletions src/packages/recompose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions src/packages/recompose/utils/mapValues.js
Original file line number Diff line number Diff line change
@@ -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
13 changes: 1 addition & 12 deletions src/packages/recompose/withHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions src/packages/recompose/withStateHandlers.js
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit dcaee67

Please sign in to comment.