Skip to content

Commit bfda97f

Browse files
committed
add experimental useAsyncFetchMore
1 parent 6e53e59 commit bfda97f

File tree

2 files changed

+124
-3
lines changed

2 files changed

+124
-3
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-async-hook",
3-
"version": "3.5.3",
3+
"version": "3.6.1",
44
"description": "Async hook",
55
"author": "Sébastien Lorber",
66
"license": "MIT",

src/index.ts

+123-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react';
1+
import {
2+
useCallback,
3+
useEffect,
4+
useLayoutEffect,
5+
useRef,
6+
useState,
7+
} from 'react';
8+
9+
// See https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
10+
const useIsomorphicLayoutEffect =
11+
typeof window !== 'undefined' &&
12+
typeof window.document !== 'undefined' &&
13+
typeof window.document.createElement !== 'undefined'
14+
? useLayoutEffect
15+
: useEffect;
16+
17+
// assign current value to a ref and providing a getter.
18+
// This way we are sure to always get latest value provided to hook and
19+
// avoid weird issues due to closures capturing stale values...
20+
// See https://overreacted.io/making-setinterval-declarative-with-react-hooks/
21+
const useGetter = <T>(t: T) => {
22+
const ref = useRef(t);
23+
useIsomorphicLayoutEffect(() => {
24+
ref.current = t;
25+
});
26+
return () => ref.current;
27+
};
228

329
type UnknownResult = unknown;
430

@@ -106,6 +132,7 @@ const normalizeOptions = <R>(
106132
type UseAsyncStateResult<R> = {
107133
value: AsyncState<R>;
108134
set: (value: AsyncState<R>) => void;
135+
merge: (value: Partial<AsyncState<R>>) => void;
109136
reset: () => void;
110137
setLoading: () => void;
111138
setResult: (r: R) => void;
@@ -137,9 +164,20 @@ const useAsyncState = <R extends {}>(
137164
[value, setValue]
138165
);
139166

167+
const set = setValue;
168+
169+
const merge = useCallback(
170+
(state: Partial<AsyncState<R>>) =>
171+
set({
172+
...value,
173+
...state,
174+
}),
175+
[value, set]
176+
);
140177
return {
141178
value,
142-
set: setValue,
179+
set,
180+
merge,
143181
reset,
144182
setLoading,
145183
setResult,
@@ -177,6 +215,7 @@ export type UseAsyncReturn<
177215
Args extends any[] = UnknownArgs
178216
> = AsyncState<R> & {
179217
set: (value: AsyncState<R>) => void;
218+
merge: (value: Partial<AsyncState<R>>) => void;
180219
reset: () => void;
181220
execute: (...args: Args) => Promise<R>;
182221
currentPromise: Promise<R> | null;
@@ -251,6 +290,7 @@ const useAsyncInternal = <R = UnknownResult, Args extends any[] = UnknownArgs>(
251290
return {
252291
...AsyncState.value,
253292
set: AsyncState.set,
293+
merge: AsyncState.merge,
254294
reset: AsyncState.reset,
255295
execute: executeAsyncOperation,
256296
currentPromise: CurrentPromise.get(),
@@ -353,3 +393,84 @@ export const useAsyncCallback = <
353393
}
354394
);
355395
};
396+
397+
export const useAsyncFetchMore = <R, Args extends any[]>({
398+
value,
399+
fetchMore,
400+
merge,
401+
isEnd: isEndFn,
402+
}: {
403+
value: UseAsyncReturn<R, Args>;
404+
fetchMore: (result: R) => Promise<R>;
405+
merge: (result: R, moreResult: R) => R;
406+
isEnd: (moreResult: R) => boolean;
407+
}) => {
408+
const getAsyncValue = useGetter(value);
409+
const [isEnd, setIsEnd] = useState(false);
410+
411+
// TODO not really fan of this id thing, we should find a way to support cancellation!
412+
const fetchMoreId = useRef(0);
413+
414+
const fetchMoreAsync = useAsyncCallback(async () => {
415+
const freshAsyncValue = getAsyncValue();
416+
if (freshAsyncValue.status !== 'success') {
417+
throw new Error(
418+
"Can't fetch more if the original fetch is not a success"
419+
);
420+
}
421+
if (fetchMoreAsync.status === 'loading') {
422+
throw new Error(
423+
"Can't fetch more, because we are already fetching more!"
424+
);
425+
}
426+
427+
fetchMoreId.current = fetchMoreId.current + 1;
428+
const currentId = fetchMoreId.current;
429+
const moreResult = await fetchMore(freshAsyncValue.result!);
430+
431+
// TODO not satisfied with this, we should just use "freshAsyncValue === getAsyncValue()" but asyncValue is not "stable"
432+
const isStillSameValue =
433+
freshAsyncValue.status === getAsyncValue().status &&
434+
freshAsyncValue.result === getAsyncValue().result;
435+
436+
const isStillSameId = fetchMoreId.current === currentId;
437+
438+
// Handle race conditions: we only merge the fetchMore result if the initial async value is the same
439+
const canMerge = isStillSameValue && isStillSameId;
440+
if (canMerge) {
441+
value.merge({
442+
result: merge(value.result!, moreResult),
443+
});
444+
if (isEndFn(moreResult)) {
445+
setIsEnd(true);
446+
}
447+
}
448+
449+
// return is useful for chaining, like fetchMore().then(result => {});
450+
return moreResult;
451+
});
452+
453+
const reset = () => {
454+
fetchMoreAsync.reset();
455+
setIsEnd(false);
456+
};
457+
458+
// We only allow to fetch more on a stable async value
459+
// If that value change for whatever reason, we reset the fetchmore too (which will make current pending requests to be ignored)
460+
// TODO value is not stable, we could just reset on value change otherwise
461+
const shouldReset = value.status !== 'success';
462+
useEffect(() => {
463+
if (shouldReset) {
464+
reset();
465+
}
466+
}, [shouldReset]);
467+
468+
return {
469+
canFetchMore:
470+
value.status === 'success' && fetchMoreAsync.status !== 'loading',
471+
loading: fetchMoreAsync.loading,
472+
status: fetchMoreAsync.status,
473+
fetchMore: fetchMoreAsync.execute,
474+
isEnd,
475+
};
476+
};

0 commit comments

Comments
 (0)