Skip to content

Commit 1b2c097

Browse files
feat: add useNestedState hook
1 parent 28aa2dc commit 1b2c097

File tree

9 files changed

+415
-27
lines changed

9 files changed

+415
-27
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Visit [storybook](https://beizhedenglong.github.io/react-hooks-lib)
2222
| [`createContextState`](#createContextStateInitial) | initial | { ContextProvider, ContextConsumer, set, useSelector, useSet } |
2323
| [`createGlobalState`](#createGlobalStateInitial) | initial | { GlobalProvider, GlobalConsumer, set, useSelector, useSet } |
2424
| [`useMergeState`](#usemergestateinitial) | initial | { state, set } |
25+
| [`useNestedState`](#usenestedstateinitial) | initial | { state, get, set } |
2526
| [`useStateCallback`](#useStateCallbackInitial-f) | initial, f | { state, set } |
2627
| [`useUndo`](#useUndoInitial) | initial | { past, present, future, set, undo, redo } |
2728
| [`useCounter`](#useCounterInitial) | initial | { count, set, reset, inc, dec } |
@@ -122,6 +123,13 @@ const MergeState = () => {
122123
)
123124
}
124125
```
126+
### `useNestedState`
127+
#### Arguments
128+
- `initial?`: Initial state, default is `undefined`.
129+
#### Returns
130+
- `state`: Current state.
131+
- `get(pathString, defaultValue)`: Get value form state at a specific `pathString`. eg: `get("a.b.c")`/`get("" | undefined)`, if `pathString` is empty,it will return the state object.
132+
- `set: (pathString, newValue | prevValue => newValue)`: Set value at a specific `pathString`. eg: `set("a.b.c", prev => prev + 1)`/`set("" | undefined, {})`. if `pathString` is empty,it will set the entire state object.
125133

126134
### `useStateCallback(initial, f?)`
127135

__tests__/useNestedState.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { renderHook, act } from '@testing-library/react-hooks'
2+
import { useNestedState } from '../src'
3+
4+
test('useNestedState', () => {
5+
const { result } = renderHook(() => useNestedState())
6+
expect(result.current.state).toEqual(undefined)
7+
expect(result.current.get('a.b.c')).toEqual(undefined)
8+
expect(result.current.get('a.b.c', 'default')).toEqual('default')
9+
act(() => {
10+
result.current.set('a.b.c', 1)
11+
})
12+
expect(result.current.state).toEqual({ a: { b: { c: 1 } } })
13+
expect(result.current.get('')).toEqual({ a: { b: { c: 1 } } })
14+
expect(result.current.get('a.b.c')).toEqual(1)
15+
act(() => {
16+
result.current.set('b[0].c', 1)
17+
result.current.set('b[0].c', prev => prev + 1)
18+
})
19+
expect(result.current.state).toEqual({ a: { b: { c: 1 } }, b: [{ c: 2 }] })
20+
})

__tests__/util.test.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11

2-
import { isNil } from '../src/utils'
2+
import {
3+
isNil, toPath, get, set,
4+
} from '../src/utils'
35

4-
test('1+1 = 2', () => {
6+
test('isNil', () => {
57
expect(isNil(2)).toBe(false)
68
})
9+
10+
11+
test('toPath', () => {
12+
expect(toPath('a.b.c')).toEqual(['a', 'b', 'c'])
13+
expect(toPath('a[0].b.c')).toEqual(['a', '0', 'b', 'c'])
14+
expect(toPath('')).toEqual([])
15+
expect(toPath([])).toEqual([])
16+
expect(toPath(123)).toEqual(['123'])
17+
})
18+
19+
20+
test('get', () => {
21+
const obj = { a: [{ b: { c: 3 } }] }
22+
23+
expect(get(obj, 'a[0].b.c')).toEqual(3)
24+
expect(get(obj, ['a', '0', 'b', 'c'])).toEqual(3)
25+
expect(get(obj, 'a.b.c', 'default')).toEqual('default')
26+
expect(get(obj)).toEqual(obj)
27+
expect(get(obj, '')).toEqual(obj)
28+
expect(get(obj, [])).toEqual(obj)
29+
})
30+
31+
test('set', () => {
32+
const obj = { a: [{ b: { c: 3 } }] }
33+
34+
expect(set(obj, 'a[0].b.c', 4)).toEqual({ a: [{ b: { c: 4 } }] })
35+
expect(set(obj, ['a', '0', 'b', 'c'], 4)).toEqual({ a: [{ b: { c: 4 } }] })
36+
// TODO
37+
expect(set(obj, 'a.b.c', 'default').a.b.c).toEqual('default')
38+
expect(set(obj, [], 3)).toEqual(3)
39+
expect(set(obj, [], 3)).toEqual(3)
40+
expect(set(obj, [])).toEqual(undefined)
41+
expect(obj).toEqual({ a: [{ b: { c: 3 } }] })
42+
expect(set(2, 'a.b.c', 3)).toEqual({ a: { b: { c: 3 } } })
43+
})

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"@babel/core": "^7.5.4"
3838
},
3939
"devDependencies": {
40-
"@babel/core": "^7.5.4",
40+
"@babel/core": "^7.12.10",
41+
"@babel/node": "^7.12.10",
4142
"@babel/plugin-proposal-object-rest-spread": "^7.5.4",
4243
"@babel/polyfill": "^7.2.5",
4344
"@babel/preset-env": "^7.2.3",

src/hooks/useNestedState.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useState } from 'react'
2+
import { get, set } from '../utils'
3+
4+
5+
const useNestedState = (initial) => {
6+
const [state, setState] = useState(initial)
7+
return {
8+
state,
9+
get: (pathString, defaultValue) => get(state, pathString, defaultValue),
10+
set: (pathString, updater) => setState(prev => (typeof updater === 'function'
11+
? set(prev, pathString, updater(get(prev, pathString)))
12+
: set(prev, pathString, updater))),
13+
}
14+
}
15+
16+
export default useNestedState

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,5 @@ export { default as useStateCallback } from './hooks/useStateCallback'
3737
export { useShallowEqualEffect, useDeepEqualEffect } from './hooks/useEqualEffect'
3838

3939
export { default as useAsync } from './hooks/useAsync'
40+
41+
export { default as useNestedState } from './hooks/useNestedState'

src/utils.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,63 @@ export const shallowEqual = /* istanbul ignore next */ (objA, objB) => {
5353

5454

5555
export const identity = x => x
56+
57+
export const toPath = (x = '') => {
58+
if (Array.isArray(x)) {
59+
return x
60+
}
61+
return x.toString().replace(/[[\]]/g, '.').split('.').reduce((acc, item) => {
62+
if (item.trim() !== '') {
63+
acc.push(item)
64+
}
65+
return acc
66+
}, [])
67+
}
68+
69+
export const get = (obj, pathString, defaultValue) => {
70+
const path = toPath(pathString)
71+
const res = path.reduce((acc, key) => {
72+
if (isNil(acc)) {
73+
return undefined
74+
}
75+
return acc[key]
76+
}, obj)
77+
return isNil(res) ? defaultValue : res
78+
}
79+
80+
export const isPlainObject = value => Object.prototype.toString.call(value) === '[object Object]'
81+
82+
83+
const copyCurrentValue = (obj, nextKey) => {
84+
if (Array.isArray(obj)) {
85+
return [
86+
...obj,
87+
]
88+
} if (isPlainObject(obj)) {
89+
return { ...obj }
90+
}
91+
if (/[0-9]+/.test(nextKey) && parseInt(nextKey, 10).toString() === nextKey.toString()) {
92+
return []
93+
}
94+
return {}
95+
}
96+
export const set = (obj, pathString, value) => {
97+
const path = toPath(pathString)
98+
if (path.length === 0) {
99+
return value
100+
}
101+
102+
const result = copyCurrentValue(obj, path[0])
103+
104+
path.reduce((acc, currentKey, index) => {
105+
const hasNext = index !== path.length - 1
106+
const nextKey = path[index + 1]
107+
if (hasNext) {
108+
acc[currentKey] = copyCurrentValue(acc[currentKey], nextKey)
109+
} else {
110+
acc[currentKey] = value
111+
}
112+
return acc[currentKey]
113+
}, result)
114+
return result
115+
}

stories/state.stories.js

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,49 @@ storiesOf(section('useMergeState'), module)
4646
return <MergeState />
4747
`)
4848

49+
storiesOf(section('useNestedState'), module)
50+
.addLiveSource('demo', `
51+
const Contacts = () => {
52+
const { state, get, set } = useNestedState([])
53+
return (
54+
<div>
55+
<div>
56+
<ol>
57+
{state.map((_, index) => (
58+
// eslint-disable-next-line react/no-array-index-key
59+
<li key={index} style={{ margin: 10, padding: 10, border: '1px solid black' }}>
60+
<label>
61+
Name:
62+
<input type="text" value={get(\`$\{index}.name\`, '')} onChange={e => set(\`$\{index}.name\`, e.target.value)} />
63+
</label>
64+
<label>
65+
Age:
66+
<input type="number" value={get(\`$\{index}.age\`, 0)} onChange={e => set(\`$\{index}.age\`, e.target.value)} />
67+
</label>
68+
<div>
69+
<h4>Extra Info:</h4>
70+
<label>
71+
Email:
72+
<input type="email" value={get(\`$\{index}.extraInfo.email\`, '')} onChange={e => set(\`$\{index}.extraInfo.email\`, e.target.value)} />
73+
</label>
74+
</div>
75+
</li>
76+
))}
77+
</ol>
78+
<button onClick={() => set('', prev => ([...prev, {}]))}>Add Contact</button>
79+
</div>
80+
<div>
81+
<h3>
82+
State:
83+
</h3>
84+
<pre>{JSON.stringify(state, null, 2)}</pre>
85+
</div>
86+
</div>
87+
)
88+
}
89+
return <Contacts />
90+
`)
91+
4992
storiesOf(section('useStateCallback'), module)
5093
.addLiveSource('demo', `
5194
const StateCallback = () => {
@@ -65,26 +108,6 @@ storiesOf(section('useStateCallback'), module)
65108
return <StateCallback />
66109
`)
67110

68-
// storiesOf(section('createContextState'), module)
69-
// .addLiveSource('demo', `
70-
// const { ContextProvider, useContextState } = createContextState({ counter: 1 })
71-
72-
// const ContextState = () => {
73-
// const { state, set } = useContextState()
74-
// return (
75-
// <div>
76-
// counter:
77-
// {state.counter}
78-
// <button onClick={() => set(
79-
// prev => ({ counter: prev.counter + 1 })
80-
// )}>+1</button>
81-
// </div>
82-
// )
83-
// }
84-
85-
// return <ContextState />
86-
// `)
87-
88111
storiesOf(section('useCounter'), module)
89112
.addLiveSource('demo', `
90113
const Counter = () => {

0 commit comments

Comments
 (0)