Skip to content

Add useAsync hook to leverage new Hooks proposal #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Dec 30, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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..."

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid isLoading, and use React Suspense instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspense is not released yet. Eventually React Async will support both. At first, React Async will maintain its API but use Suspense underneath. Later on I'll look into better integration with the Suspense components (e.g. Placeholder/Timeout). Right now there's no telling how Suspense will be used in practice.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to agree with @sibelius. It seems like any new api surface for async right now should at least consider the usage of resources, cache invalidation and suspense. The react team has mentioned that react-cache is a WIP and that invalidation is outstanding. Their vision seems to be using resource for data fetching. Any new api surface surface should at least be able to show how it fits into that future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I want React Async to be future compatible. I'm confident it will still be relevant despite Suspense offering very similar features. Personally I'm surprised that they even took the whole thing as far as providing a cache mechanism. That to me sound like a very application specific thing. A simple library around Promises could easily provide the same functionality, which is why React Async doesn't do any caching, you can simply bring your own.

So what would closer integration with Suspense look like? I'm hesitant to adopt the same API, and relying on interop between the two (i.e. using Async with Timeout or Placeholder) seems awkward and error prone. What are your thoughts on this?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'm surprised that they even took the whole thing as far as providing a cache mechanism.

I haven't dug into code that does the suspending in React reconciler, but the mechanism how WIP react-cache package gets render to suspend is through throwing a pending promise -https://github.com/facebook/react/blob/master/packages/react-cache/src/ReactCache.js#L158 Before throwing the pending promise out to React renderer, one needs to store the promise somewhere, to access it again when promise gets resolved and render is retried, hence the need for react-cache. Also I think the react-cache won't be mandatory for suspense, so anyone can roll their own:

It also serves as a reference for more advanced caching implementations.
https://github.com/facebook/react/tree/master/packages/react-cache

Atleast that's how I understand it.

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 [email protected] [email protected] && 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 [email protected] [email protected] && 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 [email protected]. Upgrade your React version or use the <Async> component instead."
)
}

export default (useState ? useAsync : unsupported)
Loading