Skip to content

Commit 0db4bb4

Browse files
authored
feat: add useAsyncSetState (#12)
* feat: add useAsyncSetState * clean up async state
1 parent 5f4ac32 commit 0db4bb4

File tree

7 files changed

+235
-17
lines changed

7 files changed

+235
-17
lines changed

.babelrc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"presets": [["@4c"], "@babel/typescript"],
2+
"presets": ["@4c", "@babel/typescript"],
33
"env": {
44
"esm": {
55
"presets": [
@@ -10,6 +10,9 @@
1010
}
1111
]
1212
]
13+
},
14+
"test": {
15+
"presets": [["@4c", { "development": true }]]
1316
}
1417
}
1518
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
},
2323
"jest": {
2424
"preset": "@4c",
25-
"setupFiles": [
25+
"setupFilesAfterEnv": [
2626
"./test/setup.js"
2727
]
2828
},

src/useAnimationFrame.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useRef } from 'react'
2-
import useWillUnmount from './useWillUnmount'
32
import useMounted from './useMounted'
43
import useStableMemo from './useStableMemo'
4+
import useWillUnmount from './useWillUnmount'
55

66
export interface UseAnimationFrameReturn {
77
cancel(): void
@@ -22,7 +22,7 @@ export interface UseAnimationFrameReturn {
2222
* Returns a controller object for requesting and cancelling an animation freame that is properly cleaned up
2323
* once the component unmounts. New requests cancel and replace existing ones.
2424
*
25-
* ```tsx
25+
* ```ts
2626
* const [style, setStyle] = useState({});
2727
* const animationFrame = useAnimationFrame();
2828
*

src/useMergeState.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react'
1+
import { useCallback, useState } from 'react'
22

33
type Updater<TState> = (state: TState) => Partial<TState> | null
44

@@ -29,17 +29,20 @@ export default function useMergeState<TState extends {}>(
2929
): [TState, MergeStateSetter<TState>] {
3030
const [state, setState] = useState<TState>(initialState)
3131

32-
const updater = (update: Updater<TState> | Partial<TState> | null) => {
33-
if (update === null) return
34-
if (typeof update === 'function') {
35-
setState(state => {
36-
const nextState = update(state)
37-
return nextState == null ? state : { ...state, ...nextState }
38-
})
39-
} else {
40-
setState(state => ({ ...state, ...update }))
41-
}
42-
}
32+
const updater = useCallback(
33+
(update: Updater<TState> | Partial<TState> | null) => {
34+
if (update === null) return
35+
if (typeof update === 'function') {
36+
setState(state => {
37+
const nextState = update(state)
38+
return nextState == null ? state : { ...state, ...nextState }
39+
})
40+
} else {
41+
setState(state => ({ ...state, ...update }))
42+
}
43+
},
44+
[setState],
45+
)
4346

4447
return [state, updater]
4548
}

src/useStateAsync.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React, { useCallback, useEffect, useRef, useState } from 'react'
2+
3+
type Updater<TState> = (state: TState) => TState
4+
5+
export type AsyncSetState<TState> = (
6+
stateUpdate: React.SetStateAction<TState>,
7+
) => Promise<TState>
8+
9+
/**
10+
* A hook that mirrors `useState` in function and API, expect that setState
11+
* calls return a promise that resolves after the state has been set (in an effect).
12+
*
13+
* This is _similar_ to the second callback in classy setState calls, but fires later.
14+
*
15+
* ```ts
16+
* const [counter, setState] = useStateAsync(1);
17+
*
18+
* const handleIncrement = async () => {
19+
* await setState(2);
20+
* doWorkRequiringCurrentState()
21+
* }
22+
* ```
23+
*
24+
* @param initialState initialize with some state value same as `useState`
25+
*/
26+
function useStateAsync<TState>(
27+
initialState: TState | (() => TState),
28+
): [TState, AsyncSetState<TState>] {
29+
const [state, setState] = useState(initialState)
30+
const resolvers = useRef<((state: TState) => void)[]>([])
31+
32+
useEffect(() => {
33+
resolvers.current.forEach(resolve => resolve(state))
34+
resolvers.current.length = 0
35+
}, [state])
36+
37+
const setStateAsync = useCallback(
38+
(update: Updater<TState> | TState) => {
39+
return new Promise<TState>((resolve, reject) => {
40+
setState(prevState => {
41+
try {
42+
let nextState: TState
43+
// ugly instanceof for typescript
44+
if (update instanceof Function) {
45+
nextState = update(prevState)
46+
} else {
47+
nextState = update
48+
}
49+
50+
// If state does not change, we must resolve the promise because
51+
// react won't re-render and effect will not resolve. If there are already
52+
// resolvers queued, then it should be safe to assume an update will happen
53+
if (!resolvers.current.length && Object.is(nextState, prevState)) {
54+
resolve(nextState)
55+
} else {
56+
resolvers.current.push(resolve)
57+
}
58+
return nextState
59+
} catch (e) {
60+
reject(e)
61+
throw e
62+
}
63+
})
64+
})
65+
},
66+
[setState],
67+
)
68+
return [state, setStateAsync]
69+
}
70+
71+
export default useStateAsync

test/setup.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import Adapter from 'enzyme-adapter-react-16'
21
import Enzyme from 'enzyme'
2+
import Adapter from 'enzyme-adapter-react-16'
33
import matchMediaPolyfill from 'mq-polyfill'
44

55
Enzyme.configure({ adapter: new Adapter() })
@@ -22,3 +22,35 @@ if (typeof window !== 'undefined') {
2222
}).dispatchEvent(new this.Event('resize'))
2323
}
2424
}
25+
26+
let expectedErrors = 0
27+
let actualErrors = 0
28+
function onError(e) {
29+
if (expectedErrors) {
30+
e.preventDefault()
31+
}
32+
actualErrors += 1
33+
}
34+
35+
expect.errors = num => {
36+
expectedErrors = num
37+
}
38+
39+
beforeEach(() => {
40+
expectedErrors = 0
41+
actualErrors = 0
42+
if (typeof window !== 'undefined') {
43+
window.addEventListener('error', onError)
44+
}
45+
})
46+
47+
afterEach(() => {
48+
if (typeof window !== 'undefined') {
49+
window.removeEventListener('error', onError)
50+
}
51+
if (expectedErrors) {
52+
expect(actualErrors).toBe(expectedErrors)
53+
}
54+
55+
expectedErrors = 0
56+
})

test/useStateAsync.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mount } from 'enzyme'
2+
import React from 'react'
3+
import { act } from 'react-dom/test-utils'
4+
import useStateAsync, { AsyncSetState } from '../src/useStateAsync'
5+
6+
describe('useStateAsync', () => {
7+
it('should increment counter', async () => {
8+
let asyncState: [number, AsyncSetState<number>]
9+
10+
function Wrapper() {
11+
asyncState = useStateAsync<number>(0)
12+
return null
13+
}
14+
15+
mount(<Wrapper />)
16+
17+
expect.assertions(4)
18+
19+
const incrementAsync = async () => {
20+
await act(() => asyncState[1](prev => prev + 1))
21+
}
22+
23+
expect(asyncState![0]).toEqual(0)
24+
25+
await incrementAsync()
26+
expect(asyncState![0]).toEqual(1)
27+
28+
await incrementAsync()
29+
expect(asyncState![0]).toEqual(2)
30+
31+
await incrementAsync()
32+
expect(asyncState![0]).toEqual(3)
33+
})
34+
35+
it('should reject on error', async () => {
36+
let asyncState: [number, AsyncSetState<number>]
37+
38+
function Wrapper() {
39+
asyncState = useStateAsync<number>(1)
40+
return null
41+
}
42+
class CatchError extends React.Component {
43+
static getDerivedStateFromError() {}
44+
componentDidCatch() {}
45+
render() {
46+
return this.props.children
47+
}
48+
}
49+
50+
mount(
51+
<CatchError>
52+
<Wrapper />
53+
</CatchError>,
54+
)
55+
56+
// @ts-ignore
57+
expect.errors(1)
58+
59+
await act(async () => {
60+
const p = asyncState[1](() => {
61+
throw new Error('yo')
62+
})
63+
return expect(p).rejects.toThrow('yo')
64+
})
65+
})
66+
67+
it('should resolve even if no update happens', async () => {
68+
let asyncState: [number, AsyncSetState<number>]
69+
70+
function Wrapper() {
71+
asyncState = useStateAsync<number>(1)
72+
return null
73+
}
74+
75+
mount(<Wrapper />)
76+
77+
expect.assertions(3)
78+
79+
expect(asyncState![0]).toEqual(1)
80+
81+
await act(() => expect(asyncState[1](1)).resolves.toEqual(1))
82+
83+
expect(asyncState![0]).toEqual(1)
84+
})
85+
86+
it('should resolve after update if already pending', async () => {
87+
let asyncState: [number, AsyncSetState<number>]
88+
89+
function Wrapper() {
90+
asyncState = useStateAsync<number>(0)
91+
return null
92+
}
93+
94+
mount(<Wrapper />)
95+
96+
expect.assertions(5)
97+
98+
expect(asyncState![0]).toEqual(0)
99+
100+
const setAndAssert = async (n: number) =>
101+
expect(asyncState[1](n)).resolves.toEqual(2)
102+
103+
await act(() =>
104+
Promise.all([setAndAssert(1), setAndAssert(1), setAndAssert(2)]),
105+
)
106+
107+
expect(asyncState![0]).toEqual(2)
108+
})
109+
})

0 commit comments

Comments
 (0)