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 }) => } + ) + 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 ( +
+ + {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 + }} + + ) + 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 }) => } + + ) + 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 ( +
+ + +
+ ) + }} +
+ ) + 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 + }} + + ) + 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