Skip to content

Commit

Permalink
Add 'watchFn' for more flexibility in reloading based on prop changes.
Browse files Browse the repository at this point in the history
  • Loading branch information
ghengeveld committed Feb 4, 2019
1 parent 1c79637 commit afe0b22
Show file tree
Hide file tree
Showing 5 changed files with 71 additions and 5 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<button onClick={this.inc}>increment</button>
{this.props.children(this.state.count)}
</div>
)
}
}
const promiseFn = jest.fn().mockReturnValue(resolveTo())
const watchFn = ({ count }, prevProps) => count !== prevProps.count && count === 2
const { getByText } = render(
<Counter>{count => <Async promiseFn={promiseFn} watchFn={watchFn} count={count} />}</Counter>
)
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())
Expand Down
7 changes: 6 additions & 1 deletion src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
() => ({
Expand Down
31 changes: 31 additions & 0 deletions src/useAsync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<button onClick={this.inc}>increment</button>
{this.props.children(this.state.count)}
</div>
)
}
}
const promiseFn = jest.fn().mockReturnValue(resolveTo())
const watchFn = ({ count }, prevProps) => count !== prevProps.count && count === 2
const component = (
<Counter>{count => <Async promiseFn={promiseFn} watchFn={watchFn} count={count} />}</Counter>
)
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())
Expand Down

0 comments on commit afe0b22

Please sign in to comment.