diff --git a/.travis.yml b/.travis.yml
index 2286c93e..59a494e1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -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
diff --git a/README.md b/README.md
index 9bf617ea..b82b01e9 100644
--- a/README.md
+++ b/README.md
@@ -31,13 +31,13 @@
-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
@@ -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 (
+
+
Loaded some data:
+
{JSON.stringify(data, null, 2)}
+
+ )
+ return null
+}
+```
+
+Or using the shorthand version:
+
+```js
+const MyComponent = () => {
+ const { data, error, isLoading } = useAsync(loadJson)
+ // ...
+}
+```
+
Using render props for ultimate flexibility:
```js
@@ -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 ``. Alternatively you can use the shorthand syntax:
+
+```js
+useState(promiseFn, initialValue)
+```
+
## Examples
### Basic data fetching with loading indicator, error state and retry
diff --git a/package.json b/package.json
index 9373f35f..20b8e26e 100644
--- a/package.json
+++ b/package.json
@@ -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": {
@@ -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/",
diff --git a/src/index.js b/src/index.js
index 9fd0714a..a780d3be 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,4 +1,5 @@
import React from "react"
+export { default as useAsync } from "./useAsync"
const isFunction = arg => typeof arg === "function"
diff --git a/src/useAsync.js b/src/useAsync.js
new file mode 100644
index 00000000..4f6319c6
--- /dev/null
+++ b/src/useAsync.js
@@ -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 component instead."
+ )
+}
+
+export default (useState ? useAsync : unsupported)
diff --git a/src/useAsync.spec.js b/src/useAsync.spec.js
new file mode 100644
index 00000000..02e84b49
--- /dev/null
+++ b/src/useAsync.spec.js
@@ -0,0 +1,322 @@
+import "jest-dom/extend-expect"
+import React from "react"
+import { render, fireEvent, cleanup, waitForElement, flushEffects } from "react-testing-library"
+import { useAsync } from "."
+
+afterEach(cleanup)
+
+const resolveIn = ms => value => new Promise(resolve => setTimeout(resolve, ms, value))
+const resolveTo = resolveIn(0)
+
+const Async = ({ children = () => null, ...props }) => children(useAsync(props))
+
+describe("useAsync", () => {
+ test("returns render props", async () => {
+ const promiseFn = () => new Promise(resolve => setTimeout(resolve, 0, "done"))
+ const component = {({ data }) => data || null}
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("done"))
+ })
+
+ test("passes rejection error to children as render prop", async () => {
+ const promiseFn = () => Promise.reject("oops")
+ const component = {({ error }) => error || null}
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("oops"))
+ })
+
+ test("passes isLoading boolean while the promise is running", async () => {
+ const promiseFn = () => resolveTo("done")
+ const states = []
+ const component = (
+
+ {({ data, isLoading }) => {
+ states.push(isLoading)
+ return data || null
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("done"))
+ expect(states).toEqual([true, true, false])
+ })
+
+ test("passes startedAt date when the promise starts", async () => {
+ const promiseFn = () => resolveTo("done")
+ const component = (
+
+ {({ startedAt }) => {
+ if (startedAt) {
+ expect(startedAt.getTime()).toBeCloseTo(new Date().getTime(), -2)
+ return "started"
+ }
+ return null
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("started"))
+ })
+
+ test("passes finishedAt date when the promise finishes", async () => {
+ const promiseFn = () => resolveTo("done")
+ const component = (
+
+ {({ data, finishedAt }) => {
+ if (finishedAt) {
+ expect(finishedAt.getTime()).toBeCloseTo(new Date().getTime(), -1)
+ return data || null
+ }
+ return null
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("done"))
+ })
+
+ test("passes reload function that re-runs the promise", async () => {
+ const promiseFn = jest.fn().mockReturnValue(resolveTo("done"))
+ const component = (
+ {({ reload }) => reload }
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ expect(promiseFn).toHaveBeenCalledTimes(1)
+ fireEvent.click(getByText("reload"))
+ expect(promiseFn).toHaveBeenCalledTimes(2)
+ })
+
+ test("re-runs the promise when the value of 'watch' changes", () => {
+ class Counter extends React.Component {
+ state = { count: 0 }
+ inc = () => this.setState(state => ({ count: state.count + 1 }))
+ render() {
+ return (
+
+ increment
+ {this.props.children(this.state.count)}
+
+ )
+ }
+ }
+ const promiseFn = jest.fn().mockReturnValue(resolveTo())
+ const component = {count => }
+ const { getByText } = render(component)
+ flushEffects()
+ expect(promiseFn).toHaveBeenCalledTimes(1)
+ fireEvent.click(getByText("increment"))
+ flushEffects()
+ expect(promiseFn).toHaveBeenCalledTimes(2)
+ fireEvent.click(getByText("increment"))
+ flushEffects()
+ expect(promiseFn).toHaveBeenCalledTimes(3)
+ })
+
+ test("runs deferFn only when explicitly invoked, passing arguments and props", () => {
+ let counter = 1
+ const deferFn = jest.fn().mockReturnValue(resolveTo())
+ const component = (
+
+ {({ run }) => {
+ return run("go", counter++)}>run
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ expect(deferFn).not.toHaveBeenCalled()
+ fireEvent.click(getByText("run"))
+ expect(deferFn).toHaveBeenCalledWith("go", 1, expect.objectContaining({ deferFn, foo: "bar" }))
+ fireEvent.click(getByText("run"))
+ expect(deferFn).toHaveBeenCalledWith("go", 2, expect.objectContaining({ deferFn, foo: "bar" }))
+ })
+
+ test("cancel will prevent the resolved promise from propagating", async () => {
+ const promiseFn = jest.fn().mockReturnValue(Promise.resolve("ok"))
+ const onResolve = jest.fn()
+ const component = (
+
+ {({ cancel }) => cancel }
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ fireEvent.click(getByText("cancel"))
+ await Promise.resolve()
+ expect(onResolve).not.toHaveBeenCalled()
+ })
+
+ test("reload uses the arguments of the previous run", () => {
+ let counter = 1
+ const deferFn = jest.fn().mockReturnValue(resolveTo())
+ const component = (
+
+ {({ run, reload }) => {
+ return (
+
+ run("go", counter++)}>run
+ reload
+
+ )
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ expect(deferFn).not.toHaveBeenCalled()
+ fireEvent.click(getByText("run"))
+ expect(deferFn).toHaveBeenCalledWith("go", 1, expect.objectContaining({ deferFn }))
+ fireEvent.click(getByText("run"))
+ expect(deferFn).toHaveBeenCalledWith("go", 2, expect.objectContaining({ deferFn }))
+ fireEvent.click(getByText("reload"))
+ expect(deferFn).toHaveBeenCalledWith("go", 2, expect.objectContaining({ deferFn }))
+ })
+
+ test("only accepts the last invocation of the promise", async () => {
+ let i = 0
+ const resolves = [resolveIn(10)("a"), resolveIn(20)("b"), resolveIn(10)("c")]
+ const component = (
+ resolves[i]}>
+ {({ data, run }) => {
+ if (data) {
+ expect(data).toBe("c")
+ return "done"
+ }
+ return run(i)}>run
+ }}
+
+ )
+ const { getByText } = render(component)
+ fireEvent.click(getByText("run"))
+ i++
+ fireEvent.click(getByText("run"))
+ i++
+ fireEvent.click(getByText("run"))
+ await waitForElement(() => getByText("done"))
+ })
+
+ test("invokes onResolve callback when promise resolves", async () => {
+ const promiseFn = jest.fn().mockReturnValue(Promise.resolve("ok"))
+ const onResolve = jest.fn()
+ const component =
+ render(component)
+ flushEffects()
+ await Promise.resolve()
+ expect(onResolve).toHaveBeenCalledWith("ok")
+ })
+
+ test("invokes onReject callback when promise rejects", async () => {
+ const promiseFn = jest.fn().mockReturnValue(Promise.reject("err"))
+ const onReject = jest.fn()
+ const component =
+ render(component)
+ flushEffects()
+ await Promise.resolve()
+ expect(onReject).toHaveBeenCalledWith("err")
+ })
+
+ test("cancels pending promise when unmounted", async () => {
+ const promiseFn = jest.fn().mockReturnValue(Promise.resolve("ok"))
+ const onResolve = jest.fn()
+ const component =
+ const { unmount } = render(component)
+ flushEffects()
+ unmount()
+ await Promise.resolve()
+ expect(onResolve).not.toHaveBeenCalled()
+ })
+
+ test("cancels and restarts the promise when promiseFn changes", async () => {
+ const promiseFn1 = jest.fn().mockReturnValue(Promise.resolve("one"))
+ const promiseFn2 = jest.fn().mockReturnValue(Promise.resolve("two"))
+ const onResolve = jest.fn()
+ const component1 =
+ const component2 =
+ const { rerender } = render(component1)
+ await Promise.resolve()
+ flushEffects()
+ expect(promiseFn1).toHaveBeenCalled()
+ rerender(component2)
+ flushEffects()
+ expect(promiseFn2).toHaveBeenCalled()
+ expect(onResolve).not.toHaveBeenCalledWith("one")
+ await Promise.resolve()
+ expect(onResolve).toHaveBeenCalledWith("two")
+ })
+
+ test("does not run promiseFn on mount when initialValue is provided", () => {
+ const promiseFn = jest.fn().mockReturnValue(Promise.resolve())
+ const component =
+ render(component)
+ flushEffects()
+ expect(promiseFn).not.toHaveBeenCalled()
+ })
+
+ test("does not start loading when using initialValue", async () => {
+ const promiseFn = () => resolveTo("done")
+ const states = []
+ const component = (
+
+ {({ data, isLoading }) => {
+ states.push(isLoading)
+ return data
+ }}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("done"))
+ expect(states).toEqual([false])
+ })
+
+ test("passes initialValue to children immediately", async () => {
+ const promiseFn = () => resolveTo("done")
+ const component = (
+
+ {({ data }) => data}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("done"))
+ })
+
+ test("sets error instead of data when initialValue is an Error object", async () => {
+ const promiseFn = () => resolveTo("done")
+ const error = new Error("oops")
+ const component = (
+
+ {({ error }) => error.message}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("oops"))
+ })
+
+ test("can be nested", async () => {
+ const outerFn = () => resolveIn(0)("outer")
+ const innerFn = () => resolveIn(100)("inner")
+ const component = (
+
+ {({ data: outer }) => (
+
+ {({ data: inner }) => {
+ return outer + " " + inner
+ }}
+
+ )}
+
+ )
+ const { getByText } = render(component)
+ flushEffects()
+ await waitForElement(() => getByText("outer undefined"))
+ await waitForElement(() => getByText("outer inner"))
+ })
+})
\ No newline at end of file
diff --git a/typings/index.d.ts b/typings/index.d.ts
index 82314ce0..fe1465bd 100644
--- a/typings/index.d.ts
+++ b/typings/index.d.ts
@@ -1,6 +1,7 @@
import * as React from "react"
type AsyncChildren = ((state: AsyncState) => React.ReactNode) | React.ReactNode
+type PromiseFn = (props: object) => Promise
interface AsyncProps {
promiseFn?: (props: object) => Promise
@@ -9,6 +10,9 @@ interface AsyncProps {
initialValue?: T
onResolve?: (data: T) => void
onError?: (error: Error) => void
+}
+
+interface AsyncProps extends AsyncOptions {
children?: AsyncChildren
}
@@ -37,4 +41,6 @@ declare namespace Async {
declare function createInstance(defaultProps?: AsyncProps): Async
+export function useAsync(opts: AsyncOptions | PromiseFn, init?: T): AsyncState
+
export default createInstance