Skip to content

Commit e65e080

Browse files
authored
Merge pull request #9 from ghengeveld/useAsync
Add `useAsync` hook to leverage new Hooks proposal
2 parents 450aad8 + 73e6cb2 commit e65e080

File tree

7 files changed

+468
-9
lines changed

7 files changed

+468
-9
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ node_js:
44
cache:
55
directories:
66
- node_modules
7-
script: npm run test:compat
7+
script: npm run test:compat && npm run test:hook
88
after_success:
99
- bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION

README.md

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@
3131
</a>
3232
</p>
3333

34-
React component for declarative promise resolution and data fetching. Leverages the Render Props pattern for ultimate
35-
flexibility as well as the new Context API for ease of use. Makes it easy to handle loading and error states, without
36-
assumptions about the shape of your data or the type of request.
34+
React component for declarative promise resolution and data fetching. Leverages the Render Props pattern and Hooks for
35+
ultimate flexibility as well as the new Context API for ease of use. Makes it easy to handle loading and error states,
36+
without assumptions about the shape of your data or the type of request.
3737

3838
- Zero dependencies
3939
- Works with any (native) promise
40-
- Choose between Render Props and Context-based helper components
40+
- Choose between Render Props, Context-based helper components or the `useAsync` hook
4141
- Provides convenient `isLoading`, `startedAt` and `finishedAt` metadata
4242
- Provides `cancel` and `reload` actions
4343
- Automatic re-run using `watch` prop
@@ -84,6 +84,37 @@ npm install --save react-async
8484

8585
## Usage
8686

87+
As a hook with `useAsync`:
88+
89+
```js
90+
import { useAsync } from "react-async"
91+
92+
const loadJson = () => fetch("/some/url").then(res => res.json())
93+
94+
const MyComponent = () => {
95+
const { data, error, isLoading } = useAsync({ promiseFn: loadJson })
96+
if (isLoading) return "Loading..."
97+
if (error) return `Something went wrong: ${error.message}`
98+
if (data)
99+
return (
100+
<div>
101+
<strong>Loaded some data:</strong>
102+
<pre>{JSON.stringify(data, null, 2)}</pre>
103+
</div>
104+
)
105+
return null
106+
}
107+
```
108+
109+
Or using the shorthand version:
110+
111+
```js
112+
const MyComponent = () => {
113+
const { data, error, isLoading } = useAsync(loadJson)
114+
// ...
115+
}
116+
```
117+
87118
Using render props for ultimate flexibility:
88119

89120
```js
@@ -186,6 +217,14 @@ Similarly, this allows you to set default `onResolve` and `onReject` callbacks.
186217
- `setData` {Function} sets `data` to the passed value, unsets `error` and cancels any pending promise
187218
- `setError` {Function} sets `error` to the passed value and cancels any pending promise
188219

220+
### `useState`
221+
222+
The `useState` hook accepts an object with the same props as `<Async>`. Alternatively you can use the shorthand syntax:
223+
224+
```js
225+
useState(promiseFn, initialValue)
226+
```
227+
189228
## Examples
190229

191230
### Basic data fetching with loading indicator, error state and retry

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@
2626
"typings"
2727
],
2828
"scripts": {
29-
"build": "babel src -d lib",
29+
"build": "rimraf lib && babel src -d lib --ignore '**/*spec.js'",
3030
"lint": "eslint src",
31-
"test": "jest src",
31+
"test": "jest src/spec.js --collectCoverageFrom=src/index.js",
3232
"test:watch": "npm run test -- --watch",
3333
"test:compat": "npm run test:backwards && npm run test:forwards && npm run test:latest",
3434
"test:backwards": "npm i [email protected] [email protected] && npm test",
3535
"test:forwards": "npm i react@next react-dom@next && npm test",
3636
"test:latest": "npm i react@latest react-dom@latest && npm test",
37-
"prepublishOnly": "npm run lint && npm run test:compat && npm run build"
37+
"test:hook": "npm i [email protected] [email protected] && jest src/useAsync.spec.js --collectCoverageFrom=src/useAsync.js",
38+
"prepublishOnly": "npm run lint && npm run test:compat && npm run test:hook && npm run build"
3839
},
3940
"dependencies": {},
4041
"peerDependencies": {
@@ -58,7 +59,8 @@
5859
"prettier": "1.15.3",
5960
"react": "16.6.3",
6061
"react-dom": "16.6.3",
61-
"react-testing-library": "5.2.3"
62+
"react-testing-library": "5.4.2",
63+
"rimraf": "2.6.2"
6264
},
6365
"jest": {
6466
"coverageDirectory": "./coverage/",

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from "react"
2+
export { default as useAsync } from "./useAsync"
23

34
const isFunction = arg => typeof arg === "function"
45

src/useAsync.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useState, useEffect, useMemo, useRef } from "react"
2+
3+
const useAsync = (opts, init) => {
4+
const counter = useRef(0)
5+
const isMounted = useRef(true)
6+
const lastArgs = useRef(undefined)
7+
8+
const options = typeof opts === "function" ? { promiseFn: opts, initialValue: init } : opts
9+
const { promiseFn, deferFn, initialValue, onResolve, onReject, watch } = options
10+
11+
const [state, setState] = useState({
12+
data: initialValue instanceof Error ? undefined : initialValue,
13+
error: initialValue instanceof Error ? initialValue : undefined,
14+
startedAt: promiseFn ? new Date() : undefined,
15+
finishedAt: initialValue ? new Date() : undefined,
16+
})
17+
18+
const handleData = (data, callback = () => {}) => {
19+
if (isMounted.current) {
20+
setState(state => ({ ...state, data, error: undefined, finishedAt: new Date() }))
21+
callback(data)
22+
}
23+
return data
24+
}
25+
26+
const handleError = (error, callback = () => {}) => {
27+
if (isMounted.current) {
28+
setState(state => ({ ...state, error, finishedAt: new Date() }))
29+
callback(error)
30+
}
31+
return error
32+
}
33+
34+
const handleResolve = count => data => count === counter.current && handleData(data, onResolve)
35+
const handleReject = count => error => count === counter.current && handleError(error, onReject)
36+
37+
const start = () => {
38+
counter.current++
39+
setState(state => ({
40+
...state,
41+
startedAt: new Date(),
42+
finishedAt: undefined,
43+
}))
44+
}
45+
46+
const load = () => {
47+
const isPreInitialized = initialValue && counter.current === 0
48+
if (promiseFn && !isPreInitialized) {
49+
start()
50+
promiseFn(options).then(handleResolve(counter.current), handleReject(counter.current))
51+
}
52+
}
53+
54+
const run = (...args) => {
55+
if (deferFn) {
56+
start()
57+
lastArgs.current = args
58+
return deferFn(...args, options).then(handleResolve(counter.current), handleReject(counter.current))
59+
}
60+
}
61+
62+
useEffect(load, [promiseFn, watch])
63+
useEffect(() => () => (isMounted.current = false), [])
64+
65+
return useMemo(
66+
() => ({
67+
...state,
68+
isLoading: state.startedAt && (!state.finishedAt || state.finishedAt < state.startedAt),
69+
initialValue,
70+
run,
71+
reload: () => (lastArgs.current ? run(...lastArgs.current) : load()),
72+
cancel: () => {
73+
counter.current++
74+
setState(state => ({ ...state, startedAt: undefined }))
75+
},
76+
setData: handleData,
77+
setError: handleError,
78+
}),
79+
[state]
80+
)
81+
}
82+
83+
const unsupported = () => {
84+
throw new Error(
85+
"useAsync requires [email protected]. Upgrade your React version or use the <Async> component instead."
86+
)
87+
}
88+
89+
export default (useState ? useAsync : unsupported)

0 commit comments

Comments
 (0)