Skip to content
This repository was archived by the owner on Mar 4, 2020. It is now read-only.

Commit 77fccb6

Browse files
authored
feat(bindings): add useStateManager() (#1905)
* feat(bindings): add `useStateManager` * fixes to README * fixes to README * remove React from peerDeps from state * merge master
1 parent f04285c commit 77fccb6

File tree

15 files changed

+724
-0
lines changed

15 files changed

+724
-0
lines changed

packages/react-bindings/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
`@stardust-ui/react-bindings`
2+
===
3+
4+
A set of reusable components and hooks to build component libraries and UI kits.
5+
6+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
7+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
8+
9+
10+
- [Installation](#installation)
11+
- [Hooks](#hooks)
12+
- [`useStateManager()`](#usestatemanager)
13+
- [Usage](#usage)
14+
- [Reference](#reference)
15+
16+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
17+
18+
# Installation
19+
20+
**NPM**
21+
```bash
22+
npm install --save @stardust-ui/react-bindings
23+
```
24+
25+
**Yarn**
26+
```bash
27+
yarn add @stardust-ui/react-bindings
28+
```
29+
30+
# Hooks
31+
32+
## `useStateManager()`
33+
34+
A React hook that provides bindings for state managers.
35+
36+
### Usage
37+
38+
The examples below assume a component called `<Input>` will be used this way:
39+
40+
```tsx
41+
type InputProps = {
42+
defaultValue?: string;
43+
value?: string;
44+
onChange?: (value: string) => void;
45+
};
46+
type InputState = { value: string };
47+
type InputActions = { change: (value: string) => void };
48+
49+
const createInputManager: ManagerFactory<InputState, InputActions> = config =>
50+
createManager<InputState, InputActions>({
51+
...config,
52+
actions: {
53+
change: (value: string) => () => ({ value })
54+
},
55+
state: { value: "", ...config.state }
56+
});
57+
58+
const Input: React.FC<InputProps> = props => {
59+
const [state, actions] = useStateManager(createInputManager, {
60+
mapPropsToInitialState: () => ({ value: props.defaultValue }),
61+
mapPropsToState: () => ({ value: props.value })
62+
});
63+
64+
return (
65+
<input
66+
onChange={e => {
67+
actions.change(e.target.value);
68+
if (props.onChange) props.onChange(e.target.value);
69+
}}
70+
value={state.value}
71+
/>
72+
);
73+
};
74+
```
75+
76+
### Reference
77+
78+
```tsx
79+
const [state, actions] = useStateManager(createInputManager)
80+
const [state, actions] = useStateManager(
81+
managerFactory: ManagerFactory<State, Actions>,
82+
options: UseStateManagerOptions<Props>,
83+
)
84+
```
85+
86+
- `managerFactory` - a factory that implements state manager API
87+
- `options.mapPropsToInitialState` - optional, maps component's props to the initial state
88+
- `options.mapPropsToState` - optional, maps component's props to the state, should be used if your component implements [controlled mode](https://reactjs.org/docs/uncontrolled-components.html).

packages/react-bindings/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"react": "^16.8.0",
2424
"react-dom": "^16.8.0"
2525
},
26+
"optionalDependencies": {
27+
"@stardust-ui/state": "^0.39.0"
28+
},
2629
"publishConfig": {
2730
"access": "public"
2831
},
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { AnyAction, SideEffect } from '@stardust-ui/state'
2+
import * as React from 'react'
3+
4+
type Dispatch<Action extends AnyAction> = (
5+
e: DispatchEvent,
6+
action: Action,
7+
...args: Parameters<Action>
8+
) => void
9+
10+
type DispatchEffect<State> = (e: DispatchEvent, prevState: State, nextState: State) => void
11+
12+
type DispatchEvent = React.SyntheticEvent | Event
13+
14+
const useDispatchEffect = <State>(
15+
dispatchEffect: DispatchEffect<State>,
16+
): [Dispatch<AnyAction>, SideEffect<State>] => {
17+
const latestEffect = React.useRef<DispatchEffect<State>>(dispatchEffect)
18+
const latestEvent = React.useRef<DispatchEvent | null>(null)
19+
20+
latestEffect.current = dispatchEffect
21+
22+
const dispatch = React.useCallback<Dispatch<AnyAction>>((e, action, ...args) => {
23+
latestEvent.current = e
24+
25+
action(...args)
26+
latestEvent.current = null
27+
}, [])
28+
const sideEffect = React.useCallback<SideEffect<State>>((prevState, nextState) => {
29+
return latestEffect.current(latestEvent.current as DispatchEvent, prevState, nextState)
30+
}, [])
31+
32+
return [dispatch, sideEffect]
33+
}
34+
35+
export default useDispatchEffect
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
AnyActions,
3+
EnhancedActions,
4+
Manager,
5+
ManagerFactory,
6+
SideEffect,
7+
} from '@stardust-ui/state'
8+
import * as React from 'react'
9+
10+
type UseStateManagerOptions<State> = {
11+
mapPropsToInitialState?: () => Partial<State>
12+
mapPropsToState?: () => Partial<State>
13+
sideEffects?: SideEffect<State>[]
14+
}
15+
16+
export const getDefinedProps = <Props extends Record<string, any>>(
17+
props: Props,
18+
): Partial<Props> => {
19+
const definedProps: Partial<Props> = {}
20+
21+
Object.keys(props).forEach(propName => {
22+
if (props[propName] !== undefined) {
23+
definedProps[propName] = props[propName]
24+
}
25+
})
26+
27+
return definedProps
28+
}
29+
30+
const useStateManager = <State extends Record<string, any>, Actions extends AnyActions>(
31+
managerFactory: ManagerFactory<State, Actions>,
32+
options: UseStateManagerOptions<State> = {},
33+
): [Readonly<State>, Readonly<Actions>] => {
34+
const {
35+
mapPropsToInitialState = () => ({} as Partial<State>),
36+
mapPropsToState = () => ({} as Partial<State>),
37+
sideEffects = [],
38+
} = options
39+
const latestManager = React.useRef<Manager<State, Actions> | null>(null)
40+
41+
// Heads up! forceUpdate() is used only for triggering rerenders stateManager is SSOT()
42+
const [, forceUpdate] = React.useState()
43+
const syncState = React.useCallback(
44+
(_prevState: State, nextState: State) => forceUpdate(nextState),
45+
[],
46+
)
47+
48+
// If manager exists, the current state will be used
49+
const initialState = latestManager.current
50+
? latestManager.current.state
51+
: getDefinedProps(mapPropsToInitialState())
52+
53+
latestManager.current = managerFactory({
54+
// Factory has already configured actions
55+
actions: {} as EnhancedActions<State, Actions>,
56+
state: { ...initialState, ...getDefinedProps(mapPropsToState()) },
57+
sideEffects: [...sideEffects, syncState],
58+
})
59+
60+
// We need to pass exactly `manager.state` to provide the same state object during the same render
61+
// frame.
62+
// It keeps behavior consistency between React state tools and our managers
63+
// https://github.com/facebook/react/issues/11527#issuecomment-360199710
64+
65+
if (process.env.NODE_ENV === 'production') {
66+
return [latestManager.current.state, latestManager.current.actions]
67+
}
68+
69+
// Object.freeze() is used only in dev-mode to avoid usage mistakes
70+
return [Object.freeze(latestManager.current.state), Object.freeze(latestManager.current.actions)]
71+
}
72+
73+
export default useStateManager

packages/react-bindings/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export { default as getElementType } from './utils/getElementType'
22
export { default as getUnhandledProps } from './utils/getUnhandledProps'
3+
4+
export { default as unstable_useDispatchEffect } from './hooks/useDispatchEffect'
5+
export { default as useStateManager } from './hooks/useStateManager'
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { unstable_useDispatchEffect, useStateManager } from '@stardust-ui/react-bindings'
2+
import { createManager, ManagerFactory } from '@stardust-ui/state'
3+
import { shallow } from 'enzyme'
4+
import * as React from 'react'
5+
import * as ReactTestUtils from 'react-dom/test-utils'
6+
7+
type TestState = { value: string }
8+
type TestActions = { change: (value: string) => void; clear: () => void }
9+
10+
const createTestManager: ManagerFactory<TestState, TestActions> = config =>
11+
createManager<TestState, TestActions>({
12+
...config,
13+
actions: {
14+
change: (value: string) => () => ({ value }),
15+
clear: () => () => ({ value: '' }),
16+
},
17+
state: {
18+
value: '',
19+
...config.state,
20+
},
21+
})
22+
23+
type TestComponentProps = Partial<TestState> & {
24+
onChange: (e: React.ChangeEvent | React.MouseEvent, value: TestComponentProps) => void
25+
defaultValue?: string
26+
value?: string
27+
}
28+
29+
const TestComponent: React.FunctionComponent<TestComponentProps> = props => {
30+
const [dispatch, dispatchEffect] = unstable_useDispatchEffect<TestState>(
31+
(e, prevState, nextState) => {
32+
if (prevState.value !== nextState.value) {
33+
props.onChange(e as React.ChangeEvent | React.MouseEvent, {
34+
...props,
35+
value: nextState.value,
36+
})
37+
}
38+
},
39+
)
40+
const [state, actions] = useStateManager(createTestManager, {
41+
mapPropsToInitialState: () => ({ value: props.defaultValue }),
42+
mapPropsToState: () => ({ value: props.value }),
43+
sideEffects: [dispatchEffect],
44+
})
45+
46+
return (
47+
<>
48+
<input onChange={e => dispatch(e, actions.change, e.target.value)} value={state.value} />
49+
<button onClick={e => dispatch(e, actions.clear)} />
50+
</>
51+
)
52+
}
53+
54+
describe('useDispatchEffect', () => {
55+
it('calls an action with params', () => {
56+
const onChange = jest.fn()
57+
const wrapper = shallow(<TestComponent onChange={onChange} />)
58+
59+
ReactTestUtils.act(() => {
60+
wrapper.find('input').simulate('change', { target: { value: 'baz' } })
61+
})
62+
63+
expect(wrapper.find('input').prop('value')).toBe('baz')
64+
})
65+
66+
it('calls an action without params', () => {
67+
const onChange = jest.fn()
68+
const wrapper = shallow(<TestComponent defaultValue="foo" onChange={onChange} />)
69+
70+
ReactTestUtils.act(() => {
71+
wrapper.find('button').simulate('click')
72+
})
73+
74+
expect(wrapper.find('input').prop('value')).toBe('')
75+
})
76+
})

0 commit comments

Comments
 (0)