Skip to content
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

Add new prop to be able to skip the initial fetch on component mount #70

Open
wants to merge 5 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ These can be passed in an object to `useAsync()`, or as props to `<Async>` and c
- `watch` Watch a value and automatically reload when it changes.
- `watchFn` Watch this function and automatically reload when it returns truthy.
- `initialValue` Provide initial data or error for server-side rendering.
- `skipOnMount` Skip running `promiseFn` on mount, useful for debounced requests.
- `onResolve` Callback invoked when Promise resolves.
- `onReject` Callback invoked when Promise rejects.
- `onCancel` Callback invoked when a Promise is cancelled.
Expand Down Expand Up @@ -400,6 +401,13 @@ Re-runs the `promiseFn` when this callback returns truthy (called on every updat

Initial state for `data` or `error` (if instance of Error); useful for server-side rendering.

#### `skipOnMount`

> `boolean`

Skips the `promiseFn` on mount and runs it only after refresh is requested in one way or another.
Useful for debounced actions to skip the initial fetch.

#### `onResolve`

> `function(data: any): void`
Expand Down
1 change: 1 addition & 0 deletions examples/debounced-search/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SKIP_PREFLIGHT_CHECK=true
7 changes: 7 additions & 0 deletions examples/debounced-search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Debounced search with useAsync hook

This demonstrates how to use the `useAsync` hook to do debounced search.

<a href="https://react-async.ghengeveld.now.sh/examples/debounced-search">
<img src="https://img.shields.io/badge/live-demo-blue.svg" alt="live demo">
</a>
41 changes: 41 additions & 0 deletions examples/debounced-search/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "debounced-search-example",
"version": "1.0.0",
"private": true,
"homepage": "https://react-async.ghengeveld.now.sh/examples/debounced-search-example",
"scripts": {
"postinstall": "relative-deps",
"prestart": "relative-deps",
"prebuild": "relative-deps",
"pretest": "relative-deps",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build"
},
"dependencies": {
"debounce": "^1.2.0",
"faker": "^4.1.0",
"react": "^16.8.6",
"react-async": "^7.0.5",
"react-async-devtools": "^1.0.3",
"react-dom": "^16.8.6",
"react-scripts": "^3.0.1"
},
"devDependencies": {
"relative-deps": "^0.1.2"
},
"relativeDependencies": {
"react-async": "../../packages/react-async/pkg",
"react-async-devtools": "../../packages/react-async-devtools/pkg"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
Binary file added examples/debounced-search/public/favicon.ico
Binary file not shown.
13 changes: 13 additions & 0 deletions examples/debounced-search/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<title>React App</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>
6 changes: 6 additions & 0 deletions examples/debounced-search/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
body {
margin: 20px;
padding: 0px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
81 changes: 81 additions & 0 deletions examples/debounced-search/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { useState, useEffect, useMemo } from "react"
import { useAsync } from "react-async"
import ReactDOM from "react-dom"
import faker from "faker"
import debounce from "debounce"
import DevTools from "react-async-devtools"

import "./index.css"

async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function loadSearchResults() {
await sleep(500)

const result = []

for (let i = 0; i < 10; i++) {
result.push(faker.name.findName())
}

return result
}

function SearchResults({ searchTerm }) {
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm)
const results = useAsync({
promiseFn: loadSearchResults,
watch: debouncedSearchTerm,
skipOnMount: true,
})

const debouncedUpdate = useMemo(
() =>
debounce(nextSearchTerm => {
setDebouncedSearchTerm(nextSearchTerm)
}, 300),
[]
)

useEffect(() => {
debouncedUpdate(searchTerm)
return () => debouncedUpdate.clear()
}, [searchTerm, debouncedUpdate])

if (results.isPending || !results.data) return <p>Loading...</p>

return (
<ul>
{results.data.map(result => (
<li key={result}>{result}</li>
))}
</ul>
)
}

function Search() {
const [searchTerm, setSearchTerm] = useState("")

return (
<>
<input
type="text"
placeholder="Search"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
{searchTerm.length > 0 ? <SearchResults searchTerm={searchTerm} /> : <p>Main view</p>}
</>
)
}

export const App = () => (
<>
<DevTools />
<Search />
</>
)

if (process.env.NODE_ENV !== "test") ReactDOM.render(<App />, document.getElementById("root"))
9 changes: 9 additions & 0 deletions examples/debounced-search/src/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react"
import ReactDOM from "react-dom"
import { App } from "./"

it("renders without crashing", () => {
const div = document.createElement("div")
ReactDOM.render(<App />, div)
ReactDOM.unmountComponentAtNode(div)
})
8 changes: 6 additions & 2 deletions packages/react-async/src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
const promise = props.promise
const promiseFn = props.promiseFn || defaultProps.promiseFn
const initialValue = props.initialValue || defaultProps.initialValue
let skipOnMount = false

if (defaultProps.skipOnMount !== undefined) skipOnMount = defaultProps.skipOnMount
if (props.skipOnMount !== undefined) skipOnMount = props.skipOnMount

this.mounted = false
this.counter = 0
this.args = []
this.abortController = { abort: () => {} }
this.state = {
...init({ initialValue, promise, promiseFn }),
...init({ initialValue, skipOnMount, promise, promiseFn }),
cancel: this.cancel,
run: this.run,
reload: () => {
Expand All @@ -60,7 +64,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {

componentDidMount() {
this.mounted = true
if (this.props.promise || !this.state.initialValue) {
if (this.props.promise || (!this.state.skipOnMount && !this.state.initialValue)) {
this.load()
}
}
Expand Down
40 changes: 40 additions & 0 deletions packages/react-async/src/Async.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,46 @@ describe("Async", () => {
)
expect(one).toBe(two)
})

test("skips the initial fetch on mount and fetches only after fetch is re-requested", async () => {
const promiseFn = () => resolveTo("data")
const { rerender, getByText, queryByText } = render(
<Async initialValue="initial" promiseFn={promiseFn} watch="1" skipOnMount>
<Async.Initial>{({ initialValue }) => <div>{initialValue}</div>}</Async.Initial>
<Async.Fulfilled>{value => <div>{value}</div>}</Async.Fulfilled>
</Async>
)

expect(queryByText("initial")).toBeInTheDocument()
expect(queryByText("data")).toBeNull()

rerender(
<Async initialValue="initial" promiseFn={promiseFn} watch="2" skipOnMount>
<Async.Initial>{({ initialValue }) => <div>{initialValue}</div>}</Async.Initial>
<Async.Fulfilled>{value => <div>{value}</div>}</Async.Fulfilled>
</Async>
)

await waitForElement(() => getByText("data"))

expect(queryByText("initial")).toBeNull()
expect(queryByText("data")).toBeInTheDocument()
})

test("does not skip the initial fetch if promise is given", async () => {
const promise = resolveTo("data")
const { getByText, queryByText } = render(
<Async initialValue="initial" promise={promise} skipOnMount>
<Async.Initial>{({ initialValue }) => <div>{initialValue}</div>}</Async.Initial>
<Async.Fulfilled>{value => <div>{value}</div>}</Async.Fulfilled>
</Async>
)

await waitForElement(() => getByText("data"))

expect(queryByText("initial")).toBeNull()
expect(queryByText("data")).toBeInTheDocument()
})
})

describe("Async.Fulfilled", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-async/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AsyncOptions<T> {
watch?: any
watchFn?: (props: object, prevProps: object) => any
initialValue?: T
skipOnMount?: boolean
onResolve?: (data: T) => void
onReject?: (error: Error) => void
reducer?: (
Expand Down
1 change: 1 addition & 0 deletions packages/react-async/src/propTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default PropTypes && {
watch: PropTypes.any,
watchFn: PropTypes.func,
initialValue: PropTypes.any,
skipOnMount: PropTypes.bool,
onResolve: PropTypes.func,
onReject: PropTypes.func,
reducer: PropTypes.func,
Expand Down
5 changes: 3 additions & 2 deletions packages/react-async/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ export const actionTypes = {
reject: "reject",
}

export const init = ({ initialValue, promise, promiseFn }) => ({
export const init = ({ initialValue, skipOnMount, promise, promiseFn }) => ({
initialValue,
skipOnMount,
data: initialValue instanceof Error ? undefined : initialValue,
error: initialValue instanceof Error ? initialValue : undefined,
value: initialValue,
startedAt: promise || promiseFn ? new Date() : undefined,
finishedAt: initialValue ? new Date() : undefined,
...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)),
...getStatusProps(getInitialStatus(initialValue, skipOnMount, promise || promiseFn)),
counter: 0,
})

Expand Down
9 changes: 5 additions & 4 deletions packages/react-async/src/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ export const statusTypes = {
rejected: "rejected",
}

export const getInitialStatus = (value, promise) => {
if (value instanceof Error) return statusTypes.rejected
if (value !== undefined) return statusTypes.fulfilled
if (promise) return statusTypes.pending
export const getInitialStatus = (initialValue, skipOnMount, promise) => {
if (initialValue instanceof Error) return statusTypes.rejected
if (initialValue !== undefined && !skipOnMount) return statusTypes.fulfilled
if (promise instanceof Promise) return statusTypes.pending
if (typeof promise === "function" && !skipOnMount) return statusTypes.pending
return statusTypes.initial
}

Expand Down
14 changes: 11 additions & 3 deletions packages/react-async/src/status.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@ import { getInitialStatus, getIdleStatus, statusTypes } from "./status"

describe("getInitialStatus", () => {
test("returns 'initial' when given an undefined value", () => {
expect(getInitialStatus(undefined)).toEqual(statusTypes.initial)
expect(getInitialStatus(undefined, false, undefined)).toEqual(statusTypes.initial)
})
test("returns 'initial' when requested to skip on mount", () => {
expect(getInitialStatus("initial", true, () => Promise.resolve("foo"))).toEqual(
statusTypes.initial
)
})
test("returns 'pending' when requested to skip on mount but given a promise", () => {
expect(getInitialStatus("initial", true, Promise.resolve("foo"))).toEqual(statusTypes.pending)
})
test("returns 'pending' when given only a promise", () => {
expect(getInitialStatus(undefined, Promise.resolve("foo"))).toEqual(statusTypes.pending)
expect(getInitialStatus(undefined, false, Promise.resolve("foo"))).toEqual(statusTypes.pending)
})
test("returns 'rejected' when given an Error value", () => {
expect(getInitialStatus(new Error("oops"))).toEqual(statusTypes.rejected)
})
test("returns 'fulfilled' when given any other value", () => {
expect(getInitialStatus(null)).toEqual(statusTypes.fulfilled)
expect(getInitialStatus(null, false)).toEqual(statusTypes.fulfilled)
})
})

Expand Down
17 changes: 12 additions & 5 deletions packages/react-async/src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,15 @@ const useAsync = (arg1, arg2) => {
})
}

const { promise, promiseFn, initialValue } = options
const { promise, promiseFn } = options
const load = () => {
if (promise) {
return start(() => promise).then(
handleResolve(counter.current),
handleReject(counter.current)
)
}
const isPreInitialized = initialValue && counter.current === 0
if (promiseFn && !isPreInitialized) {
if (promiseFn) {
return start(() => promiseFn(options, abortController.current)).then(
handleResolve(counter.current),
handleReject(counter.current)
Expand All @@ -97,13 +96,21 @@ const useAsync = (arg1, arg2) => {
isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() })
}

const { watch, watchFn } = options
const { watch, watchFn, initialValue, skipOnMount = false } = options
useEffect(() => {
if (watchFn && prevOptions.current && watchFn(options, prevOptions.current)) load()
})
useEffect(() => {
if (counter.current) cancel()
if (promise || promiseFn) load()
if (promise) load()
// promiseFn is given AND is not onMount with initialValue AND is not onMount with skipOnMount
// => use promiseFn on mount if not initialValue or skipOnMount AND if not on mount, always use promiseFn
else if (
promiseFn &&
!(initialValue && !prevOptions.current) &&
!(skipOnMount && !prevOptions.current)
)
load()
}, [promise, promiseFn, watch])
useEffect(() => () => (isMounted.current = false), [])
useEffect(() => () => cancel(), [])
Expand Down
Loading