Skip to content

Commit 861b2a4

Browse files
Added useAsyncState and useAsyncWatcher hooks;
1 parent 8f4dc16 commit 861b2a4

File tree

6 files changed

+341
-31
lines changed

6 files changed

+341
-31
lines changed

README.md

+80-5
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,66 @@ export default function TestComponent(props) {
318318
}
319319
````
320320

321+
### useAsyncState
322+
323+
A simple enhancement of the useState hook for use inside async routines.
324+
The only difference, the setter function returns a promise, that will be resolved with the current raw state value.
325+
If no arguments provided, it will return the current raw state value without changing the state.
326+
It's not guaranteed that the resolved state is the final computed state value.
327+
328+
````javascript
329+
export default function TestComponent7(props) {
330+
331+
const [counter, setCounter] = useAsyncState(0);
332+
333+
const [fn, cancel, pending, done, result, err] = useAsyncCallback(function* (value) {
334+
return (yield cpAxios(`https://rickandmortyapi.com/api/character/${value}`)).data;
335+
}, {states: true})
336+
337+
return (
338+
<div className="component">
339+
<div>{pending ? "loading..." : (done ? err ? err.toString() : JSON.stringify(result, null, 2) : "")}</div>
340+
<button onClick={async()=>{
341+
const updatedValue= await setCounter((counter)=> counter + 1);
342+
await fn(updatedValue); // pass the state value as an argument
343+
}}>Inc</button>
344+
{<button onClick={cancel} disabled={!pending}>Cancel async effect</button>}
345+
</div>
346+
);
347+
}
348+
````
349+
350+
### useAsyncWatcher
351+
352+
This hook is a promisified abstraction on top of the `useEffect` hook. The hook returns the watcher function that resolves
353+
its promise when one of the watched dependencies have changed.
354+
355+
````javascript
356+
export default function TestComponent7(props) {
357+
const [value, setValue] = useState(0);
358+
359+
const [fn, cancel, pending, done, result, err] = useAsyncCallback(function* () {
360+
console.log('inside callback the value is:', value);
361+
return (yield cpAxios(`https://rickandmortyapi.com/api/character/${value}`)).data;
362+
}, {states: true, deps: [value]})
363+
364+
const callbackWatcher = useAsyncWatcher(fn);
365+
366+
return (
367+
<div className="component">
368+
<div className="caption">useAsyncWatcher demo:</div>
369+
<div>{pending ? "loading..." : (done ? err ? err.toString() : JSON.stringify(result, null, 2) : "")}</div>
370+
<input value={value} type="number" onChange={async ({target}) => {
371+
setValue(target.value * 1);
372+
const [fn]= await callbackWatcher();
373+
await fn();
374+
}}/>
375+
{<button onClick={cancel} disabled={!pending}>Cancel async effect</button>}
376+
</div>
377+
);
378+
}
379+
````
380+
321381
To learn more about available features, see the c-promise2 [documentation](https://www.npmjs.com/package/c-promise2).
322382

323383
### Wiki
@@ -352,7 +412,7 @@ Generator context (`this`) refers to the CPromise instance.
352412
- `error: object` - refers to the error object. This var is always set when an error occurs.
353413
- `canceled:boolean` - is set to true if the function has been failed with a `CanceledError`.
354414

355-
All these vars are defined on the returned `cancelFn` function and can be alternative reached through
415+
All these vars defined on the returned `cancelFn` function and can be alternative reached through
356416
the iterator interface in the following order:
357417
````javascript
358418
const [cancelFn, done, result, error, canceled]= useAsyncEffect(/*code*/);
@@ -376,16 +436,31 @@ Generator context (`this`) and the first argument (if `options.scopeArg` is set)
376436

377437
#### Available state vars:
378438
- `pending: boolean` - the function is in the pending state
379-
- `done: boolean` - the function execution is completed (with success or failure)
439+
- `done: boolean` - the function execution completed (with success or failure)
380440
- `result: any` - refers to the resolved function result
381-
- `error: object` - refers to the error object. This var is always set when an error occurs.
441+
- `error: object` - refers to the error object. This var always set when an error occurs.
382442
- `canceled:boolean` - is set to true if the function has been failed with a `CanceledError`.
383443

384-
All these vars are defined on the decorated function and can be alternative reached through
444+
All these vars defined on the decorated function and can be alternative reached through
385445
the iterator interface in the following order:
386446
````javascript
387-
const [decoratedFn, cancelFn, pending, done, result, error, canceled]= useAsyncCallback(/*code*/);
447+
const [decoratedFn, cancel, pending, done, result, error, canceled]= useAsyncCallback(/*code*/);
388448
````
449+
### useAsyncState([initialValue]): ([value: any, accessor: function])
450+
#### arguments
451+
- `initialValue`
452+
#### returns
453+
Iterable of:
454+
- `value: any` - current state value
455+
- `accessor:(newValue)=>Promise<rawStateValue:any>` - promisified setter function that can be used
456+
as a getter if called without arguments
457+
458+
### useAsyncWatcher([...valuesToWatch]): watcherFn
459+
#### arguments
460+
- `...valuesToWatch: any` - any values to watch that will be passed to the internal effect hook
461+
#### returns
462+
- `watcherFn: ([grabPrevValue= false]): Promise<[newValue, [prevValue]]>` - if the hook is watching one value
463+
- `watcherFn: ([grabPrevValue= false]): Promise<[...[newValue, [prevValue]]]>` - if the hook is watching multiple values
389464

390465
## Related projects
391466
- [c-promise2](https://www.npmjs.com/package/c-promise2) - promise with cancellation, decorators, timeouts, progress capturing, pause and user signals support

lib/use-async-effect.js

+92-25
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,14 @@ const useAsyncEffect = (generator, options) => {
138138
const argsToPromiseMap = new Map();
139139

140140
/**
141-
* @typedef {function} UseAsyncCallbackDecoratedFn
142-
* @param {CPromise} scope
141+
* @typedef {Function} UseAsyncCallbackDecoratedFn
142+
* @param {CPromise} [scope]
143143
* @property {CancelFn} cancel
144-
* @returns {function|*}
144+
* @returns {*}
145145
*//**
146-
* @typedef {function} UseAsyncCallbackDecoratedFn
146+
* @typedef {Function} UseAsyncCallbackDecoratedFn
147147
* @property {CancelFn} cancel
148-
* @returns {function|*}
148+
* @returns {*}
149149
*/
150150

151151
/**
@@ -166,7 +166,7 @@ const useAsyncCallback = (generator, options) => {
166166
throw TypeError('options must be an object or array');
167167
}
168168

169-
const initialState= {
169+
const initialState = {
170170
done: false,
171171
result: undefined,
172172
error: undefined,
@@ -179,7 +179,8 @@ const useAsyncCallback = (generator, options) => {
179179
queue: [],
180180
pending: 0,
181181
args: null,
182-
state: initialState
182+
state: initialState,
183+
callback: null
183184
});
184185

185186
let {
@@ -188,9 +189,9 @@ const useAsyncCallback = (generator, options) => {
188189
cancelPrevious = false,
189190
threads,
190191
queueSize = -1,
191-
scopeArg= false,
192-
states= false,
193-
catchErrors= false
192+
scopeArg = false,
193+
states = false,
194+
catchErrors = false
194195
} = options && Array.isArray(options) ? {deps: options} : options || {};
195196

196197
if (threads === undefined) {
@@ -205,28 +206,28 @@ const useAsyncCallback = (generator, options) => {
205206
}
206207
}
207208

208-
if (queueSize!==-1 && (!Number.isFinite(queueSize) || queueSize < -1)) {
209+
if (queueSize !== -1 && (!Number.isFinite(queueSize) || queueSize < -1)) {
209210
throw Error('queueSize must be a finite number >=-1');
210211
}
211212

212-
const [state, setState]= states? useState(initialState) : [];
213+
const [state, setState] = states ? useState(initialState) : [];
213214

214215
const setDeepState = (newState) => {
215216
if (isEqualObjects(current.state, newState)) return;
216217
setState(newState);
217218
current.state = newState;
218219
}
219220

220-
const singleThreaded= threads === 1;
221+
const singleThreaded = threads === 1;
221222

222-
const callback = useMemo(()=>{
223-
const cancel= (reason) => {
223+
const callback = useMemo(() => {
224+
const cancel = (reason) => {
224225
const _reason = isEvent(reason) ? undefined : reason;
225226
current.promises.forEach(promise => promise.cancel(_reason));
226227
current.promises.length = 0;
227228
};
228229

229-
const fn= (...args) => {
230+
const fn = (...args) => {
230231
const {promises, queue} = current;
231232
let n;
232233

@@ -252,10 +253,10 @@ const useAsyncCallback = (generator, options) => {
252253
let started;
253254

254255
let promise = new CPromise(resolve => {
255-
const start= ()=> {
256+
const start = () => {
256257
current.pending++;
257258

258-
started= true;
259+
started = true;
259260

260261
if (states && singleThreaded) {
261262
setDeepState({
@@ -277,22 +278,22 @@ const useAsyncCallback = (generator, options) => {
277278
start();
278279
}).weight(0)
279280
.then(resolveGenerator)
280-
[catchErrors? 'done': 'finally']((value, isRejected) => {
281+
[catchErrors ? 'done' : 'finally']((value, isRejected) => {
281282
started && current.pending--;
282283
removeElement(promises, promise);
283284
combine && argsToPromiseMap.delete(promise);
284285
queue.length && queue.shift()();
285-
const canceled= !!(isRejected && CanceledError.isCanceledError(value));
286+
const canceled = !!(isRejected && CanceledError.isCanceledError(value));
286287

287-
if(canceled && (value.code === E_REASON_UNMOUNTED || value.code === E_REASON_RESTART)){
288+
if (canceled && (value.code === E_REASON_UNMOUNTED || value.code === E_REASON_RESTART)) {
288289
return;
289290
}
290291

291292
states && singleThreaded && setDeepState({
292293
pending: false,
293294
done: true,
294-
error: isRejected? value : undefined,
295-
result: isRejected? undefined : value,
295+
error: isRejected ? value : undefined,
296+
result: isRejected ? undefined : value,
296297
canceled
297298
});
298299
}).weight(0).aggregate();
@@ -304,7 +305,7 @@ const useAsyncCallback = (generator, options) => {
304305
return promise;
305306
}
306307

307-
const promise = resolveGenerator()[catchErrors? 'done' : 'finally'](() => {
308+
const promise = resolveGenerator()[catchErrors ? 'done' : 'finally'](() => {
308309
removeElement(promises, promise);
309310
combine && argsToPromiseMap.delete(promise);
310311
}).weight(0).aggregate();
@@ -318,7 +319,9 @@ const useAsyncCallback = (generator, options) => {
318319
return promise;
319320
}
320321

321-
fn.cancel= cancel;
322+
fn.cancel = cancel;
323+
324+
current.callback= fn;
322325

323326
return fn;
324327
}, deps);
@@ -348,10 +351,74 @@ const useAsyncCallback = (generator, options) => {
348351

349352
return callback;
350353
}
354+
/**
355+
* useAsyncState hook whose setter returns a promise
356+
* @param {*} [initialValue]
357+
* @returns {[any, function(*=, boolean=): (Promise<*>|undefined)]}
358+
*/
359+
const useAsyncState = (initialValue) => {
360+
const [value, setter] = useState(initialValue);
361+
362+
return [
363+
value,
364+
/**
365+
* state async accessor
366+
* @param {*} [newValue]
367+
* @returns {Promise<*>|undefined}
368+
*/
369+
function (newValue) {
370+
return new Promise(resolve => {
371+
setter((currentValue) => {
372+
if (arguments.length) {
373+
currentValue = typeof newValue === 'function' ? newValue(currentValue) : newValue;
374+
}
375+
resolve(currentValue);
376+
return currentValue;
377+
})
378+
});
379+
}
380+
]
381+
}
382+
383+
const useAsyncWatcher = (...value) => {
384+
const ref = useRef({
385+
callbacks: [],
386+
oldValue: value
387+
});
388+
389+
const multiple= value.length > 1;
390+
391+
useEffect(() => {
392+
const {current} = ref;
393+
const data = multiple ? value.map((value, index) => [value, current.oldValue[index]]) :
394+
[value[0], current.oldValue[0]];
395+
current.callbacks.forEach(cb => cb(data));
396+
current.callbacks = [];
397+
current.oldValue = value;
398+
}, value);
399+
400+
/**
401+
* @param {Boolean} [grabPrevValue]
402+
* @returns {Promise}
403+
*/
404+
return (grabPrevValue) => {
405+
return new Promise((resolve) => {
406+
ref.current.callbacks.push(entry => {
407+
if (multiple) {
408+
resolve(grabPrevValue ? entry : entry.map(values => values[0]))
409+
}
410+
411+
resolve(grabPrevValue ? entry : entry[0]);
412+
});
413+
});
414+
}
415+
}
351416

352417
module.exports = {
353418
useAsyncEffect,
354419
useAsyncCallback,
420+
useAsyncState,
421+
useAsyncWatcher,
355422
CanceledError,
356423
E_REASON_UNMOUNTED,
357424
E_REASON_RESTART

playground/src/App.js

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import TestComponent3 from "./TestComponent3";
55
import TestComponent4 from "./TestComponent4";
66
import TestComponent5 from "./TestComponent5";
77
import TestComponent6 from "./TestComponent6";
8+
import TestComponent7 from "./TestComponent7";
9+
import TestComponent8 from "./TestComponent8";
810

911
export default class App extends React.Component {
1012
constructor(props) {
@@ -69,6 +71,8 @@ export default class App extends React.Component {
6971
<TestComponent4 url={this.state.url} timeout={this.state.timeout}></TestComponent4>
7072
<TestComponent5 url={this.state.url} timeout={this.state.timeout}></TestComponent5>
7173
<TestComponent6 url={this.state.url} timeout={this.state.timeout}></TestComponent6>
74+
<TestComponent7 url={this.state.url} timeout={this.state.timeout}></TestComponent7>
75+
<TestComponent8 url={this.state.url} timeout={this.state.timeout}></TestComponent8>
7276
<button className="btn btn-danger" onClick={this.onClick}>
7377
Remount component
7478
</button>

playground/src/TestComponent7.js

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from "react";
2+
import {
3+
useAsyncCallback,
4+
useAsyncState
5+
} from "../../lib/use-async-effect";
6+
import cpAxios from "cp-axios";
7+
8+
export default function TestComponent7(props) {
9+
10+
const [counter, setCounter] = useAsyncState(0);
11+
12+
const [fn, cancel, pending, done, result, err] = useAsyncCallback(function* (value) {
13+
console.log('inside callback value is:', value);
14+
return (yield cpAxios(`https://rickandmortyapi.com/api/character/${value}`)).data;
15+
}, {states: true})
16+
17+
return (
18+
<div className="component">
19+
<div className="caption">useAsyncState demo:</div>
20+
<div>{pending ? "loading..." : (done ? err ? err.toString() : JSON.stringify(result, null, 2) : "")}</div>
21+
<button onClick={async()=>{
22+
const updatedValue= await setCounter((counter)=> counter + 1);
23+
await fn(updatedValue);
24+
}}>Inc</button>
25+
{<button onClick={cancel} disabled={!pending}>Cancel async effect</button>}
26+
</div>
27+
);
28+
}

0 commit comments

Comments
 (0)