Skip to content

Commit 0728bf5

Browse files
authored
Merge pull request #16 from ghengeveld/abortcontroller
Add AbortController to enable fetch cancelation (closes #15)
2 parents 14268c5 + 5b02e61 commit 0728bf5

File tree

13 files changed

+263
-77
lines changed

13 files changed

+263
-77
lines changed

README.md

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,18 @@ ultimate flexibility as well as the new Context API for ease of use. Makes it ea
3636
without assumptions about the shape of your data or the type of request.
3737

3838
- Zero dependencies
39-
- Works with any (native) promise
39+
- Works with any (native) Promise and the Fetch API
4040
- Choose between Render Props, Context-based helper components or the `useAsync` hook
4141
- Provides convenient `isLoading`, `startedAt` and `finishedAt` metadata
4242
- Provides `cancel` and `reload` actions
4343
- Automatic re-run using `watch` prop
4444
- Accepts `onResolve` and `onReject` callbacks
45+
- Supports [abortable fetch] by providing an AbortController
4546
- Supports optimistic updates using `setData`
4647
- Supports server-side rendering through `initialValue`
4748
- Works well in React Native too!
4849

49-
> Versions 1.x and 2.x of `react-async` on npm are from a different project abandoned years ago. The original author was
50-
> kind enough to transfer ownership so the `react-async` package name could be repurposed. The first version of
51-
> React Async is v3.0.0.
50+
[abortable fetch]: https://developers.google.com/web/updates/2017/09/abortable-fetch
5251

5352
## Rationale
5453

@@ -84,46 +83,19 @@ npm install --save react-async
8483

8584
## Usage
8685

87-
As a hook with `useAsync`:
88-
89-
```js
90-
import { useAsync } from "react-async"
91-
92-
const loadJson = () => fetch("/some/url").then(res => res.json())
93-
94-
const MyComponent = () => {
95-
const { data, error, isLoading } = useAsync({ promiseFn: loadJson })
96-
if (isLoading) return "Loading..."
97-
if (error) return `Something went wrong: ${error.message}`
98-
if (data)
99-
return (
100-
<div>
101-
<strong>Loaded some data:</strong>
102-
<pre>{JSON.stringify(data, null, 2)}</pre>
103-
</div>
104-
)
105-
return null
106-
}
107-
```
108-
109-
Or using the shorthand version:
110-
111-
```js
112-
const MyComponent = () => {
113-
const { data, error, isLoading } = useAsync(loadJson)
114-
// ...
115-
}
116-
```
117-
118-
Using render props for ultimate flexibility:
86+
Using render props for flexibility:
11987

12088
```js
12189
import Async from "react-async"
12290

123-
const loadJson = () => fetch("/some/url").then(res => res.json())
91+
// Your promiseFn receives all props from Async and an AbortController instance
92+
const loadCustomer = ({ customerId }, { signal }) =>
93+
fetch(`/api/customers/${customerId}`, { signal })
94+
.then(res => (res.ok ? res : Promise.reject(res)))
95+
.then(res => res.json())
12496

12597
const MyComponent = () => (
126-
<Async promiseFn={loadJson}>
98+
<Async promiseFn={loadCustomer} customerId={1}>
12799
{({ data, error, isLoading }) => {
128100
if (isLoading) return "Loading..."
129101
if (error) return `Something went wrong: ${error.message}`
@@ -145,10 +117,13 @@ Using helper components (don't have to be direct children) for ease of use:
145117
```js
146118
import Async from "react-async"
147119

148-
const loadJson = () => fetch("/some/url").then(res => res.json())
120+
const loadCustomer = ({ customerId }, { signal }) =>
121+
fetch(`/api/customers/${customerId}`, { signal })
122+
.then(res => (res.ok ? res : Promise.reject(res)))
123+
.then(res => res.json())
149124

150125
const MyComponent = () => (
151-
<Async promiseFn={loadJson}>
126+
<Async promiseFn={loadCustomer} customerId={1}>
152127
<Async.Loading>Loading...</Async.Loading>
153128
<Async.Resolved>
154129
{data => (
@@ -168,28 +143,67 @@ Creating a custom instance of Async, bound to a specific promiseFn:
168143
```js
169144
import { createInstance } from "react-async"
170145

171-
const loadCustomer = ({ customerId }) => fetch(`/api/customers/${customerId}`).then(...)
146+
const loadCustomer = ({ customerId }, { signal }) =>
147+
fetch(`/api/customers/${customerId}`, { signal })
148+
.then(res => (res.ok ? res : Promise.reject(res)))
149+
.then(res => res.json())
172150

173151
// createInstance takes a defaultProps object and a displayName (both optional)
174152
const AsyncCustomer = createInstance({ promiseFn: loadCustomer }, "AsyncCustomer")
175153

176154
const MyComponent = () => (
177-
<AsyncCustomer customerId="123">
155+
<AsyncCustomer customerId={1}>
178156
<AsyncCustomer.Resolved>{customer => `Hello ${customer.name}`}</AsyncCustomer.Resolved>
179157
</AsyncCustomer>
180158
)
181159
```
182160

183-
Similarly, this allows you to set default `onResolve` and `onReject` callbacks.
161+
> Similarly, this allows you to set default `onResolve` and `onReject` callbacks.
162+
163+
As a hook with `useAsync` (currently [only in React v16.7.0-alpha](https://reactjs.org/hooks)):
164+
165+
```js
166+
import { useAsync } from "react-async"
167+
168+
const loadCustomer = ({ customerId }, { signal }) =>
169+
fetch(`/api/customers/${customerId}`, { signal })
170+
.then(res => (res.ok ? res : Promise.reject(res)))
171+
.then(res => res.json())
172+
173+
const MyComponent = () => {
174+
const { data, error, isLoading } = useAsync({ promiseFn: loadCustomer, customerId: 1 })
175+
if (isLoading) return "Loading..."
176+
if (error) return `Something went wrong: ${error.message}`
177+
if (data)
178+
return (
179+
<div>
180+
<strong>Loaded some data:</strong>
181+
<pre>{JSON.stringify(data, null, 2)}</pre>
182+
</div>
183+
)
184+
return null
185+
}
186+
```
187+
188+
Or using the shorthand version:
189+
190+
```js
191+
const MyComponent = () => {
192+
const { data, error, isLoading } = useAsync(loadCustomer)
193+
// ...
194+
}
195+
```
196+
197+
The shorthand version does not support passing additional props.
184198

185199
## API
186200

187201
### Props
188202

189203
`<Async>` takes the following properties:
190204

191-
- `promiseFn` {() => Promise} A function that returns a promise; invoked in `componentDidMount` and `componentDidUpdate`; receives props (object) as argument
192-
- `deferFn` {() => Promise} A function that returns a promise; invoked only by calling `run`, with arguments being passed through, as well as props (object) as final argument
205+
- `promiseFn` {(props, controller) => Promise} A function that returns a promise; invoked in `componentDidMount` and `componentDidUpdate`; receives component props (object) and AbortController instance as arguments
206+
- `deferFn` {(...args, props, controller) => Promise} A function that returns a promise; invoked only by calling `run`, with arguments being passed through, as well as component props (object) and AbortController as final arguments
193207
- `watch` {any} Watches this property through `componentDidUpdate` and re-runs the `promiseFn` when the value changes (`oldValue !== newValue`)
194208
- `initialValue` {any} initial state for `data` or `error` (if instance of Error); useful for server-side rendering
195209
- `onResolve` {Function} Callback function invoked when a promise resolves, receives data as argument
@@ -217,12 +231,12 @@ Similarly, this allows you to set default `onResolve` and `onReject` callbacks.
217231
- `setData` {Function} sets `data` to the passed value, unsets `error` and cancels any pending promise
218232
- `setError` {Function} sets `error` to the passed value and cancels any pending promise
219233

220-
### `useState`
234+
### `useAsync`
221235

222-
The `useState` hook accepts an object with the same props as `<Async>`. Alternatively you can use the shorthand syntax:
236+
The `useAsync` hook accepts an object with the same props as `<Async>`. Alternatively you can use the shorthand syntax:
223237

224238
```js
225-
useState(promiseFn, initialValue)
239+
useAsync(promiseFn, initialValue)
226240
```
227241

228242
## Examples
@@ -424,4 +438,6 @@ Renders only while the deferred promise is still pending (not yet run).
424438

425439
## Acknowledgements
426440

427-
Many thanks to Andrey Popp for handing over ownership of `react-async` on npm.
441+
Versions 1.x and 2.x of `react-async` on npm are from a different project abandoned years ago. The original author was
442+
kind enough to transfer ownership so the `react-async` package name could be repurposed. The first version of
443+
React Async is v3.0.0. Many thanks to Andrey Popp for handing over ownership of `react-async` on npm.

examples/with-abortcontroller/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SKIP_PREFLIGHT_CHECK=true
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[![Deploy to now](https://deploy.now.sh/static/button.svg)](https://deploy.now.sh/?repo=https://github.com/ghengeveld/react-async/tree/master/examples/basic-fetch)
2+
3+
# Basic fetch with React Async
4+
5+
This demonstrates a very simple HTTP GET using `fetch`, wrapped with React Async.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"version": 2,
3+
"builds": [{ "src": "package.json", "use": "@now/static-build" }],
4+
"routes": [
5+
{ "src": "^/static/(.*)", "dest": "/static/$1" },
6+
{ "src": "^/favicon.ico", "dest": "/favicon.ico" },
7+
{ "src": "^/(.*)", "dest": "/index.html" }
8+
]
9+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "basic-fetch",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"react": "16.7.0-alpha.2",
7+
"react-async": "latest",
8+
"react-dom": "16.7.0-alpha.2",
9+
"react-scripts": "2.1.2"
10+
},
11+
"scripts": {
12+
"start": "react-scripts start",
13+
"build": "react-scripts build",
14+
"now-build": "npm run build && mv build dist"
15+
},
16+
"eslintConfig": {
17+
"extends": "react-app"
18+
},
19+
"browserslist": [
20+
">0.2%",
21+
"not dead",
22+
"not ie <= 11",
23+
"not op_mini all"
24+
]
25+
}
3.78 KB
Binary file not shown.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
6+
<meta name="theme-color" content="#000000" />
7+
<title>React App</title>
8+
</head>
9+
<body>
10+
<noscript> You need to enable JavaScript to run this app. </noscript>
11+
<div id="root"></div>
12+
</body>
13+
</html>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
body {
2+
margin: 20px;
3+
padding: 0;
4+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
5+
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
6+
-webkit-font-smoothing: antialiased;
7+
-moz-osx-font-smoothing: grayscale;
8+
}
9+
10+
button {
11+
background: none;
12+
color: palevioletred;
13+
border: 2px solid palevioletred;
14+
border-radius: 5px;
15+
padding: 10px 20px;
16+
font-size: 0.9em;
17+
font-weight: bold;
18+
outline: 0;
19+
text-transform: uppercase;
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react"
2+
import { useAsync } from "react-async"
3+
import ReactDOM from "react-dom"
4+
import "./index.css"
5+
6+
const download = (event, props, controller) =>
7+
fetch(`https://reqres.in/api/users/1?delay=3`, { signal: controller.signal })
8+
.then(res => (res.ok ? res : Promise.reject(res)))
9+
.then(res => res.json())
10+
11+
const App = () => {
12+
const { run, cancel, isLoading } = useAsync({ deferFn: download })
13+
return (
14+
<>
15+
{isLoading ? <button onClick={cancel}>cancel</button> : <button onClick={run}>start</button>}
16+
{isLoading ? (
17+
<p>Loading...</p>
18+
) : (
19+
<p>Inspect network traffic to see requests being canceled.</p>
20+
)}
21+
</>
22+
)
23+
}
24+
25+
ReactDOM.render(<App />, document.getElementById("root"))

src/index.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
1414
constructor(props) {
1515
super(props)
1616

17+
this.start = this.start.bind(this)
1718
this.load = this.load.bind(this)
1819
this.run = this.run.bind(this)
1920
this.cancel = this.cancel.bind(this)
@@ -30,6 +31,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
3031
this.mounted = false
3132
this.counter = 0
3233
this.args = []
34+
this.abortController = { abort: () => {} }
3335
this.state = {
3436
initialValue,
3537
data: initialData,
@@ -56,8 +58,8 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
5658
componentDidUpdate(prevProps) {
5759
if (prevProps.watch !== this.props.watch) this.load()
5860
if (prevProps.promiseFn !== this.props.promiseFn) {
59-
this.cancel()
6061
if (this.props.promiseFn) this.load()
62+
else this.cancel()
6163
}
6264
}
6365

@@ -66,28 +68,39 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
6668
this.mounted = false
6769
}
6870

71+
start() {
72+
if ("AbortController" in window) {
73+
this.abortController.abort()
74+
this.abortController = new window.AbortController()
75+
}
76+
this.counter++
77+
this.setState({ isLoading: true, startedAt: new Date(), finishedAt: undefined })
78+
}
79+
6980
load() {
7081
const promiseFn = this.props.promiseFn || defaultProps.promiseFn
7182
if (!promiseFn) return
72-
this.counter++
73-
this.setState({ isLoading: true, startedAt: new Date(), finishedAt: undefined })
74-
return promiseFn(this.props).then(this.onResolve(this.counter), this.onReject(this.counter))
83+
this.start()
84+
return promiseFn(this.props, this.abortController).then(
85+
this.onResolve(this.counter),
86+
this.onReject(this.counter)
87+
)
7588
}
7689

7790
run(...args) {
7891
const deferFn = this.props.deferFn || defaultProps.deferFn
7992
if (!deferFn) return
80-
this.counter++
8193
this.args = args
82-
this.setState({ isLoading: true, startedAt: new Date(), finishedAt: undefined })
83-
return deferFn(...args, this.props).then(
94+
this.start()
95+
return deferFn(...args, this.props, this.abortController).then(
8496
this.onResolve(this.counter),
8597
this.onReject(this.counter)
8698
)
8799
}
88100

89101
cancel() {
90102
this.counter++
103+
this.abortController.abort()
91104
this.setState({ isLoading: false, startedAt: undefined })
92105
}
93106

0 commit comments

Comments
 (0)