Skip to content

Commit aa6e76b

Browse files
Improved useAsyncCallback queue logic;
Fixed a bug with missing stage change that should be generated by the `useAsyncCallback` when deps changed; Added a Demo App to the playground to discover `useAsyncCallback` options set;
1 parent 11db863 commit aa6e76b

File tree

7 files changed

+361
-178
lines changed

7 files changed

+361
-178
lines changed

README.md

+134-61
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,79 @@
55
[![Stars](https://badgen.net/github/stars/DigitalBrainJS/use-async-effect)](https://github.com/DigitalBrainJS/use-async-effect/stargazers)
66

77
## useAsyncEffect2 :snowflake:
8-
This library provides `useAsyncEffect` and `useAsyncCallback` React hooks that make possible to cancel async code
9-
inside it and unsubscribe the internal routines if needed (request, timers etc.).
10-
These hooks are very useful for data fetching, since you don't have to worry about request
11-
cancellation when components unmounts. So just forget about `isMounted` flag and `AbortController`,
12-
which were used to protect your components from React leak warning, these hooks do that for you automatically.
13-
Writing cancellable async code becomes easy.
8+
9+
This library provides asynchronous versions of the `useEffect` and` useCallback` React hooks that can cancel
10+
asynchronous code inside it due to timeouts, user requests, or automatically when a component is unmounted or some
11+
dependency has changed.
12+
13+
The library is designed to make it as easy as possible to use complex and composite asynchronous routines
14+
in React components. It works on top of a [custom cancellable promise](https://www.npmjs.com/package/c-promise2),
15+
simplifying the solution to many common challenges with asynchronous code. Can be composed with cancellable version of `Axios`
16+
([cp-axios](https://www.npmjs.com/package/cp-axios)) and `fetch API` ([cp-fetch](https://www.npmjs.com/package/cp-fetch))
17+
to get auto cancellable React async effects/callbacks with network requests.
18+
19+
### Quick start
20+
21+
1. You have to use the generator syntax instead of ECMA async functions, basically by replacing `await` with `yield`
22+
and `async()=>{}` or `async function()` with `function*`:
23+
24+
````javascript
25+
// plain React useEffect hook
26+
useEffect(()=>{
27+
(async()=>{
28+
await somePromiseHandle;
29+
})();
30+
}, [])
31+
// useAsyncEffect React hook
32+
useAsyncEffect(function*(){
33+
yield somePromiseHandle;
34+
}, [])
35+
````
36+
37+
1. It's recommended to use [`CPromise`](https://www.npmjs.com/package/c-promise2) instead of the native Promise to make
38+
the promise chain deeply cancellable, at least if you're going to change the component state inside it.
39+
40+
````javascript
41+
import { CPromise } from "c-promise2";
42+
43+
const MyComponent= ()=>{
44+
const [text, setText]= useState('');
45+
46+
useAsyncEffect(function*(){
47+
yield CPromise.delay(1000);
48+
setText('Hello!');
49+
});
50+
}
51+
````
52+
53+
1. Don't catch (or just rethrow caught) `CanceledError` errors with `E_REASON_UNMOUNTED`
54+
reason inside your code before making any stage change:
55+
56+
````javascript
57+
import {
58+
useAsyncEffect,
59+
E_REASON_UNMOUNTED,
60+
CanceledError
61+
} from "use-async-effect2";
62+
import cpAxios from "cp-axios";
63+
64+
const MyComponent= ()=>{
65+
const [text, setText]= useState('');
66+
67+
useAsyncEffect(function*(){
68+
try{
69+
const json= (yield cpAxios('http://localhost/')).data;
70+
setText(`Data: ${JSON.stringify(json)}`);
71+
}catch(err){
72+
// just rethrow the CanceledError error if it has E_REASON_UNMOUNTED reason
73+
CanceledError.rethrow(err, E_REASON_UNMOUNTED);
74+
// otherwise work with it somehow
75+
setText(`Failed: ${err.toString}`);
76+
}
77+
});
78+
}
79+
````
80+
1481
## Installation :hammer:
1582
- Install for node.js using npm/yarn:
1683

@@ -37,9 +104,9 @@ When used in conjunction with other libraries from CPromise ecosystem,
37104
such as [cp-fetch](https://www.npmjs.com/package/cp-fetch) and [cp-axios](https://www.npmjs.com/package/cp-axios),
38105
you get a powerful tool for building asynchronous logic of React components.
39106

40-
`useAsyncEffect` & `useAsyncCallback` hooks make cancelable async functions from generators,
41-
providing the ability to cancel async sequence in any stage in automatically way, when the related component unmounting.
42-
It's totally the same as async functions, but with cancellation- you need use 'yield' instead of 'await'. That's all.
107+
## Examples
108+
109+
### useAsyncEffect
43110

44111
A tiny `useAsyncEffect` demo with JSON fetching using internal states:
45112

@@ -84,6 +151,60 @@ function JSONViewer(props) {
84151
````
85152
Notice: the related network request will be aborted, when unmounting.
86153

154+
An example with a timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29?file=/src/TestComponent.js)):
155+
````jsx
156+
import React, { useState } from "react";
157+
import { useAsyncEffect, E_REASON_UNMOUNTED, CanceledError} from "use-async-effect2";
158+
import cpFetch from "cp-fetch";
159+
160+
export default function TestComponent(props) {
161+
const [text, setText] = useState("");
162+
const [isPending, setIsPending] = useState(true);
163+
164+
const cancel = useAsyncEffect(
165+
function* ({ onCancel }) {
166+
console.log("mount");
167+
168+
this.timeout(props.timeout);
169+
170+
onCancel(() => console.log("scope canceled"));
171+
172+
try {
173+
setText("fetching...");
174+
const response = yield cpFetch(props.url);
175+
const json = yield response.json();
176+
setIsPending(false);
177+
setText(`Success: ${JSON.stringify(json)}`);
178+
} catch (err) {
179+
CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough for UNMOUNTED rejection
180+
setIsPending(false);
181+
setText(`Failed: ${err}`);
182+
}
183+
184+
return () => {
185+
console.log("unmount");
186+
};
187+
},
188+
[props.url]
189+
);
190+
191+
return (
192+
<div className="component">
193+
<div className="caption">useAsyncEffect demo:</div>
194+
<div>{text}</div>
195+
<button onClick={cancel} disabled={!isPending}>
196+
Cancel request
197+
</button>
198+
</div>
199+
);
200+
}
201+
````
202+
203+
### useAsyncCallback
204+
205+
Here's a [Demo App](https://codesandbox.io/s/use-async-callback-demo-app-yyic4?file=/src/TestComponent.js) to play with
206+
`asyncCallback` and learn about its options.
207+
87208
Live search for character from the `rickandmorty` universe using `rickandmortyapi.com`:
88209

89210
[Live demo](https://codesandbox.io/s/use-async-effect-axios-rickmorty-search-ui-sd2mv?file=/src/TestComponent.js)
@@ -136,54 +257,6 @@ export default function TestComponent(props) {
136257
This code handles the cancellation of the previous search sequence (including aborting the request) and
137258
canceling the sequence when the component is unmounted to avoid the React leak warning.
138259

139-
An example with a timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29?file=/src/TestComponent.js)):
140-
````jsx
141-
import React, { useState } from "react";
142-
import { useAsyncEffect, E_REASON_UNMOUNTED, CanceledError} from "use-async-effect2";
143-
import cpFetch from "cp-fetch";
144-
145-
export default function TestComponent(props) {
146-
const [text, setText] = useState("");
147-
const [isPending, setIsPending] = useState(true);
148-
149-
const cancel = useAsyncEffect(
150-
function* ({ onCancel }) {
151-
console.log("mount");
152-
153-
this.timeout(props.timeout);
154-
155-
onCancel(() => console.log("scope canceled"));
156-
157-
try {
158-
setText("fetching...");
159-
const response = yield cpFetch(props.url);
160-
const json = yield response.json();
161-
setIsPending(false);
162-
setText(`Success: ${JSON.stringify(json)}`);
163-
} catch (err) {
164-
CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough for UNMOUNTED rejection
165-
setIsPending(false);
166-
setText(`Failed: ${err}`);
167-
}
168-
169-
return () => {
170-
console.log("unmount");
171-
};
172-
},
173-
[props.url]
174-
);
175-
176-
return (
177-
<div className="component">
178-
<div className="caption">useAsyncEffect demo:</div>
179-
<div>{text}</div>
180-
<button onClick={cancel} disabled={!isPending}>
181-
Cancel request
182-
</button>
183-
</div>
184-
);
185-
}
186-
````
187260
`useAsyncCallback` example: fetch with progress capturing & cancellation
188261
([Live demo](https://codesandbox.io/s/use-async-callback-axios-catch-ui-l30h5?file=/src/TestComponent.js)):
189262
````javascript
@@ -293,15 +366,15 @@ Generator context (`this`) and the first argument (if `options.scopeArg` is set)
293366
- `deps?: any[] | UseAsyncCallbackOptions` - effect dependencies
294367
#### UseAsyncCallbackOptions:
295368
- `deps: any[]` - effect dependencies
296-
- `combine:boolean` - subscribe to the result of the async function already
297-
running with the same arguments or run a new one.
369+
- `combine:boolean` - subscribe to the result of the async function already running with the same arguments instead
370+
of running a new one.
298371
- `cancelPrevious:boolean` - cancel the previous pending async function before running a new one.
299-
- `threads: number=0` - set concurrency limit for simultaneous calls. `0` mean unlimited.
372+
- `threads: number=0` - set concurrency limit for simultaneous calls. `0` means unlimited.
300373
- `queueSize: number=0` - set max queue size.
301374
- `scopeArg: boolean=false` - pass `CPromise` scope to the generator function as the first argument.
302375
- `states: boolean=false` - enable state changing. The function must be single threaded to use the states.
303376

304-
#### Available states vars:
377+
#### Available state vars:
305378
- `pending: boolean` - the function is in the pending state
306379
- `done: boolean` - the function execution is completed (with success or failure)
307380
- `result: any` - refers to the resolved function result

lib/use-async-effect.js

+32-27
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ const isEqualObjects = require('is-equal-objects/dist/is-equal-objects.cjs');
44

55
const {E_REASON_QUEUE_OVERFLOW, E_REASON_RESTART} = CanceledError.registerErrors({
66
E_REASON_QUEUE_OVERFLOW: 'overflow',
7-
E_REASON_RESTART: 'restarted',
7+
E_REASON_RESTART: 'restarted'
88
})
99

10-
const {hasOwnProperty}= Object.prototype;
11-
1210
const isGeneratorFn = (thing) => typeof thing === 'function' &&
1311
thing.constructor &&
1412
thing.constructor.name === 'GeneratorFunction';
@@ -128,6 +126,7 @@ const useAsyncEffect = (generator, options) => {
128126
});
129127

130128
return () => {
129+
ref.current.isMounted= false;
131130
cancel(E_REASON_UNMOUNTED);
132131
cb && cb();
133132
}
@@ -170,29 +169,34 @@ const useAsyncCallback = (generator, options) => {
170169
promises: [],
171170
queue: [],
172171
pending: 0,
173-
args: null
172+
args: null,
173+
mounted: true
174174
});
175175

176176
let {
177177
deps = [],
178178
combine = false,
179179
cancelPrevious = false,
180-
threads = 0,
181-
queueSize = 0,
180+
threads,
181+
queueSize = -1,
182182
scopeArg= false,
183183
states= false
184184
} = options && Array.isArray(options) ? {deps: options} : options || {};
185185

186-
if (cancelPrevious && (threads || queueSize)) {
187-
throw Error('cancelPrevious cannot be used in conjunction with threads or queueSize options');
188-
}
186+
if (threads === undefined) {
187+
threads = cancelPrevious || states ? 1 : 0;
188+
} else {
189+
if (cancelPrevious && threads !== -1) {
190+
throw Error('can not activate function multithreading when cancelPrevious is set');
191+
}
189192

190-
if (threads && (!Number.isFinite(queueSize) || threads < 0)) {
191-
throw Error('threads must be a positive number');
193+
if (!Number.isFinite(threads) || threads < 0) {
194+
throw Error('threads must be a positive number');
195+
}
192196
}
193197

194-
if (queueSize && (!Number.isFinite(queueSize) || queueSize < 0)) {
195-
throw Error('queueSize must be a positive number');
198+
if (queueSize!==-1 && (!Number.isFinite(queueSize) || queueSize < -1)) {
199+
throw Error('queueSize must be a finite number >=-1');
196200
}
197201

198202
const [state, setState]= states? useState({
@@ -203,12 +207,7 @@ const useAsyncCallback = (generator, options) => {
203207
pending: false
204208
}) : [];
205209

206-
if(cancelPrevious){
207-
threads= 1;
208-
queueSize= 0;
209-
}
210-
211-
const singleThreaded= threads === 1 && !combine;
210+
const singleThreaded= threads === 1;
212211

213212
const callback = useCallback((...args) => {
214213
const {promises, queue} = current;
@@ -219,20 +218,24 @@ const useAsyncCallback = (generator, options) => {
219218
while (n-- > 0) {
220219
promise = promises[n];
221220
if (argsToPromiseMap.has(promise) && isEqualObjects(argsToPromiseMap.get(promise), args)) {
221+
if (cancelPrevious) {
222+
promise.cancel(E_REASON_RESTART);
223+
break;
224+
}
222225
return CPromise.resolve(promise);
223226
}
224227
}
225228
}
226229

227-
if (queueSize && (queueSize - promises.length) <= 0) {
230+
if (queueSize !== -1 && (queueSize - promises.length - current.pending) <= 0) {
228231
return CPromise.reject(CanceledError.from(E_REASON_QUEUE_OVERFLOW))
229232
}
230233

231234
const resolveGenerator = () => CPromise.run(generator, {args, resolveSignatures: true, scopeArg});
232235

233-
cancelPrevious && cancel(E_REASON_RESTART);
236+
cancelPrevious && !combine && cancel(E_REASON_RESTART);
234237

235-
if (threads) {
238+
if (threads || queueSize!==-1) {
236239
const promise = new CPromise(resolve => {
237240
if (current.pending === threads) {
238241
return queue.push(() => {
@@ -260,11 +263,7 @@ const useAsyncCallback = (generator, options) => {
260263

261264
const canceled= !!(isRejected && CanceledError.isCanceledError(value));
262265

263-
if( canceled && value.code===E_REASON_UNMOUNTED){
264-
return;
265-
}
266-
267-
if(states && singleThreaded){
266+
if(states && singleThreaded && current.mounted){
268267
setState({
269268
done: true,
270269
pending: false,
@@ -326,6 +325,12 @@ const useAsyncCallback = (generator, options) => {
326325
return () => cancel(E_REASON_UNMOUNTED);
327326
}, deps);
328327

328+
if (states) {
329+
useEffect(() => () => {
330+
current.mounted = false;
331+
}, []);
332+
}
333+
329334
callback.cancel = cancel;
330335

331336
return callback;

0 commit comments

Comments
 (0)