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

withStateHandlers (new withState) #421

Merged
merged 2 commits into from
Jul 6, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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