Skip to content

Commit

Permalink
Merge pull request #9 from ghengeveld/useAsync
Browse files Browse the repository at this point in the history
Add `useAsync` hook to leverage new Hooks proposal
  • Loading branch information
ghengeveld committed Dec 30, 2018
2 parents 450aad8 + 73e6cb2 commit e65e080
Show file tree
Hide file tree
Showing 7 changed files with 468 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ node_js:
cache:
directories:
- node_modules
script: npm run test:compat
script: npm run test:compat && npm run test:hook
after_success:
- bash <(curl -s https://codecov.io/bash) -e TRAVIS_NODE_VERSION
47 changes: 43 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@
</a>
</p>

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

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

## Usage

As a hook with `useAsync`:

```js
import { useAsync } from "react-async"

const loadJson = () => fetch("/some/url").then(res => res.json())

const MyComponent = () => {
const { data, error, isLoading } = useAsync({ promiseFn: loadJson })
if (isLoading) return "Loading..."
if (error) return `Something went wrong: ${error.message}`
if (data)
return (
<div>
<strong>Loaded some data:</strong>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
return null
}
```

Or using the shorthand version:

```js
const MyComponent = () => {
const { data, error, isLoading } = useAsync(loadJson)
// ...
}
```

Using render props for ultimate flexibility:

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

### `useState`

The `useState` hook accepts an object with the same props as `<Async>`. Alternatively you can use the shorthand syntax:

```js
useState(promiseFn, initialValue)
```

## Examples

### Basic data fetching with loading indicator, error state and retry
Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@
"typings"
],
"scripts": {
"build": "babel src -d lib",
"build": "rimraf lib && babel src -d lib --ignore '**/*spec.js'",
"lint": "eslint src",
"test": "jest src",
"test": "jest src/spec.js --collectCoverageFrom=src/index.js",
"test:watch": "npm run test -- --watch",
"test:compat": "npm run test:backwards && npm run test:forwards && npm run test:latest",
"test:backwards": "npm i react@16.3.1 react-dom@16.3.1 && npm test",
"test:forwards": "npm i react@next react-dom@next && npm test",
"test:latest": "npm i react@latest react-dom@latest && npm test",
"prepublishOnly": "npm run lint && npm run test:compat && npm run build"
"test:hook": "npm i react@16.7.0-alpha.2 react-dom@16.7.0-alpha.2 && jest src/useAsync.spec.js --collectCoverageFrom=src/useAsync.js",
"prepublishOnly": "npm run lint && npm run test:compat && npm run test:hook && npm run build"
},
"dependencies": {},
"peerDependencies": {
Expand All @@ -58,7 +59,8 @@
"prettier": "1.15.3",
"react": "16.6.3",
"react-dom": "16.6.3",
"react-testing-library": "5.2.3"
"react-testing-library": "5.4.2",
"rimraf": "2.6.2"
},
"jest": {
"coverageDirectory": "./coverage/",
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react"
export { default as useAsync } from "./useAsync"

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

Expand Down
89 changes: 89 additions & 0 deletions src/useAsync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState, useEffect, useMemo, useRef } from "react"

const useAsync = (opts, init) => {
const counter = useRef(0)
const isMounted = useRef(true)
const lastArgs = useRef(undefined)

const options = typeof opts === "function" ? { promiseFn: opts, initialValue: init } : opts
const { promiseFn, deferFn, initialValue, onResolve, onReject, watch } = options

const [state, setState] = useState({
data: initialValue instanceof Error ? undefined : initialValue,
error: initialValue instanceof Error ? initialValue : undefined,
startedAt: promiseFn ? new Date() : undefined,
finishedAt: initialValue ? new Date() : undefined,
})

const handleData = (data, callback = () => {}) => {
if (isMounted.current) {
setState(state => ({ ...state, data, error: undefined, finishedAt: new Date() }))
callback(data)
}
return data
}

const handleError = (error, callback = () => {}) => {
if (isMounted.current) {
setState(state => ({ ...state, error, finishedAt: new Date() }))
callback(error)
}
return error
}

const handleResolve = count => data => count === counter.current && handleData(data, onResolve)
const handleReject = count => error => count === counter.current && handleError(error, onReject)

const start = () => {
counter.current++
setState(state => ({
...state,
startedAt: new Date(),
finishedAt: undefined,
}))
}

const load = () => {
const isPreInitialized = initialValue && counter.current === 0
if (promiseFn && !isPreInitialized) {
start()
promiseFn(options).then(handleResolve(counter.current), handleReject(counter.current))
}
}

const run = (...args) => {
if (deferFn) {
start()
lastArgs.current = args
return deferFn(...args, options).then(handleResolve(counter.current), handleReject(counter.current))
}
}

useEffect(load, [promiseFn, watch])
useEffect(() => () => (isMounted.current = false), [])

return useMemo(
() => ({
...state,
isLoading: state.startedAt && (!state.finishedAt || state.finishedAt < state.startedAt),
initialValue,
run,
reload: () => (lastArgs.current ? run(...lastArgs.current) : load()),
cancel: () => {
counter.current++
setState(state => ({ ...state, startedAt: undefined }))
},
setData: handleData,
setError: handleError,
}),
[state]
)
}

const unsupported = () => {
throw new Error(
"useAsync requires react@16.7.0-alpha. Upgrade your React version or use the <Async> component instead."
)
}

export default (useState ? useAsync : unsupported)
Loading

0 comments on commit e65e080

Please sign in to comment.