diff --git a/README.md b/README.md index 5bc0d27d..fa9267b6 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ without assumptions about the shape of your data or the type of request. - 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 +- Automatic re-run using `watch` or `watchFn` prop - Accepts `onResolve` and `onReject` callbacks - Supports [abortable fetch] by providing an AbortController - Supports optimistic updates using `setData` @@ -205,6 +205,7 @@ The shorthand version currently does not support passing additional props. - `promiseFn` {(props, controller) => Promise} A function that returns a promise; invoked in `componentDidMount` and `componentDidUpdate`; receives component props (object) and AbortController instance as arguments - `deferFn` {(...args, props, controller) => Promise} A function that returns a promise; invoked only by calling `run(...args)`, with arguments being passed through, as well as component props (object) and AbortController as final arguments - `watch` {any} Watches this property through `componentDidUpdate` and re-runs the `promiseFn` when the value changes (`oldValue !== newValue`) +- `watchFn` {(props, prevProps) => any} Re-runs the `promiseFn` when this callback returns truthy (called on every update). - `initialValue` {any} initial state for `data` or `error` (if instance of Error); useful for server-side rendering - `onResolve` {Function} Callback function invoked when a promise resolves, receives data as argument - `onReject` {Function} Callback function invoked when a promise rejects, receives error as argument diff --git a/src/index.js b/src/index.js index 1a006e17..5e8b0d40 100644 --- a/src/index.js +++ b/src/index.js @@ -56,9 +56,11 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { } componentDidUpdate(prevProps) { - if (prevProps.watch !== this.props.watch) this.load() - if (prevProps.promiseFn !== this.props.promiseFn) { - if (this.props.promiseFn) this.load() + const { watch, watchFn, promiseFn } = this.props + if (watch !== prevProps.watch) this.load() + if (watchFn && watchFn(this.props, prevProps)) this.load() + if (promiseFn !== prevProps.promiseFn) { + if (promiseFn) this.load() else this.cancel() } } diff --git a/src/spec.js b/src/spec.js index 8a365bfe..4bf64dfa 100644 --- a/src/spec.js +++ b/src/spec.js @@ -129,6 +129,33 @@ describe("Async", () => { expect(abortCtrl.abort).toHaveBeenCalledTimes(2) }) + test("re-runs the promise when 'watchFn' returns truthy", () => { + 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 watchFn = ({ count }, prevProps) => count !== prevProps.count && count === 2 + const { getByText } = render( + {count => } + ) + expect(promiseFn).toHaveBeenCalledTimes(1) + fireEvent.click(getByText("increment")) + expect(promiseFn).toHaveBeenCalledTimes(1) + expect(abortCtrl.abort).toHaveBeenCalledTimes(0) + fireEvent.click(getByText("increment")) + expect(promiseFn).toHaveBeenCalledTimes(2) + expect(abortCtrl.abort).toHaveBeenCalledTimes(1) + }) + test("runs deferFn only when explicitly invoked, passing arguments, props and AbortController", () => { let counter = 1 const deferFn = jest.fn().mockReturnValue(resolveTo()) diff --git a/src/useAsync.js b/src/useAsync.js index 51ce9b3f..c6d0e81b 100644 --- a/src/useAsync.js +++ b/src/useAsync.js @@ -4,10 +4,11 @@ const useAsync = (opts, init) => { const counter = useRef(0) const isMounted = useRef(true) const lastArgs = useRef(undefined) + const prevOptions = useRef(undefined) const abortController = useRef({ abort: () => {} }) const options = typeof opts === "function" ? { promiseFn: opts, initialValue: init } : opts - const { promiseFn, deferFn, initialValue, onResolve, onReject, watch } = options + const { promiseFn, deferFn, initialValue, onResolve, onReject, watch, watchFn } = options const [state, setState] = useState({ data: initialValue instanceof Error ? undefined : initialValue, @@ -76,9 +77,13 @@ const useAsync = (opts, init) => { setState(state => ({ ...state, startedAt: undefined })) } + useEffect(() => { + if (watchFn && prevOptions.current && watchFn(options, prevOptions.current)) load() + }) useEffect(() => (promiseFn ? load() && undefined : cancel()), [promiseFn, watch]) useEffect(() => () => (isMounted.current = false), []) useEffect(() => abortController.current.abort, []) + useEffect(() => (prevOptions.current = options) && undefined) return useMemo( () => ({ diff --git a/src/useAsync.spec.js b/src/useAsync.spec.js index 38101e5a..2525ae6e 100644 --- a/src/useAsync.spec.js +++ b/src/useAsync.spec.js @@ -127,6 +127,37 @@ describe("useAsync", () => { expect(abortCtrl.abort).toHaveBeenCalledTimes(2) }) + test("re-runs the promise when 'watchFn' returns truthy", () => { + 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 watchFn = ({ count }, prevProps) => count !== prevProps.count && count === 2 + const component = ( + {count => } + ) + const { getByText } = render(component) + flushEffects() + expect(promiseFn).toHaveBeenCalledTimes(1) + fireEvent.click(getByText("increment")) + flushEffects() + expect(promiseFn).toHaveBeenCalledTimes(1) + expect(abortCtrl.abort).toHaveBeenCalledTimes(0) + fireEvent.click(getByText("increment")) + flushEffects() + expect(promiseFn).toHaveBeenCalledTimes(2) + expect(abortCtrl.abort).toHaveBeenCalledTimes(1) + }) + test("runs deferFn only when explicitly invoked, passing arguments and props", () => { let counter = 1 const deferFn = jest.fn().mockReturnValue(resolveTo())