react-solid-flow
Version:
[SolidJS](https://www.solidjs.com/docs/latest/api#control-flow)-inspired basic control-flow components and everyday async state hook library for [React](https://reactjs.org/)
178 lines (163 loc) • 6.06 kB
text/typescript
import { useCallback, useEffect, useRef } from "react";
import type { Resource } from "../models/Resource";
import { useResourceReducer } from "./useResourceReducer";
export type ResourceReturn<T, TArgs extends readonly unknown[]> = [
Resource<T>,
{
/** Manually set the value.
*
* If fetcher was currently pending, it's aborted.
*/
mutate: (v: Awaited<T>) => void;
/**
* Call refetch with supplied args.
*
* Fetcher opts added automatically. If fetcher was currently pending, it's aborted.
*/
refetch: (...args: TArgs) => Promise<T> | T;
/** Imperatively abort the current fetcher call.
*
* If abort is performed with no reason, or with AbortError instance, then
* the state is still considered pending/refreshing, resource.error is
* not updated, and onError callback is not called.
* Any other reason will result in erorred resource state.
*
* Resource won't be refetched untill deps change again.
*/
abort: (reason?: any) => void;
},
];
export type ResourceOptions<T> = {
/** Initial value for the resource */
initialValue?: Awaited<T> | (() => Awaited<T>);
/** resolve callback */
onCompleted?: (data: Awaited<T>) => void;
/** rejection callback */
onError?: (error: unknown) => void;
/** Skip first run (before params change) */
skipFirstRun?: boolean;
/** Skip calls of fetcher (can still be called manually with refresh)
*
* Can be useful if you're waiting for some of deps to be in certain state
* before calling the fetcher or if you want to trigger the fetcher only
* manually on some event.
*/
skip?: boolean;
/** Don't memoize getter, rerun it every time it changes */
skipFnMemoization?: boolean;
};
export interface FetcherOpts {
/** is true, if the call to fetcher was triggered manually with refetch function,
* false otherwise */
refetching: boolean;
/** can be used to abort operations in fetcher function, i.e. passed to fetch options */
signal: AbortSignal;
}
export function useResource<T, TArgs extends readonly any[]>(
fetcher:
| ((...args: [ ...TArgs, FetcherOpts ]) => Promise<T> | T)
| ((...args: [ ...TArgs ]) => Promise<T> | T),
deps: [...TArgs] = [] as unknown as [...TArgs],
{
initialValue,
onCompleted,
onError,
skipFirstRun = false,
skip = false,
skipFnMemoization,
}: ResourceOptions<T> = {},
): ResourceReturn<T, TArgs> {
// it's actually initialized in the effect bellow, so we don't create empty controllers
// on each render
const controller = useRef<AbortController>();
const skipFirst = useRef<boolean>(skipFirstRun);
const [ resource, dispatch ] = useResourceReducer(initialValue, skip || skipFirstRun);
const mutate = useCallback((val: Awaited<T>) => {
controller.current?.abort();
controller.current = new AbortController();
dispatch({ type: "SYNC-RESULT", payload: val });
}, [dispatch]);
const fetcherFn = useCallback(
(refetching: boolean, ...args: [ ...TArgs ]): T | Promise<T> => {
let val: Promise<T> | T;
const cont = controller.current;
try {
// in theory, this error should never happen, but better be on the safe side
if (cont == null) {
throw new Error("resource state error, abort controller is null during the fetch operation");
}
val = fetcher(...args, {
signal: cont.signal,
refetching,
});
if (val instanceof Promise) {
handler(val);
} else {
dispatch({ type: "SYNC-RESULT", payload: val as Awaited<T>});
}
return val;
} catch(e) {
dispatch({type: "REJECT", payload: e});
if (refetching) {
throw e;
}
return undefined as never;
}
async function handler(val: Promise<T>) {
dispatch({ type: "PEND" });
try {
const result = await val;
// As fetcher can completely ignore AbortController we're checking
// for race conditions separately, by checking that AbortController
// instance hasn't changed between calls.
if (cont !== controller.current) { return }
dispatch({ type: "RESOLVE", payload: result });
onCompleted?.(result);
} catch (e) {
if (isAbortError(e)) { return }
if (cont !== controller.current) { return }
dispatch({ type: "REJECT", payload: e });
onError?.(e);
}
}
},
skipFnMemoization ? [ fetcher ] : [],
);
const refetch = useCallback((...args: TArgs) => {
controller.current?.abort();
controller.current = new AbortController();
return fetcherFn(true, ...args);
}, [fetcherFn]);
const abort = useCallback((reason?: any) => {
controller.current?.abort(reason);
}, []);
useEffect(() => {
skipFirst.current = skipFirstRun;
if (!controller.current) {
controller.current = new AbortController();
}
}, [ skipFirstRun ]);
useEffect(() => {
if (skipFirst.current) {
skipFirst.current = false;
return;
}
if (skip) {
return;
}
fetcherFn(false, ...deps);
return () => {
controller.current?.abort();
controller.current = new AbortController();
};
// onCompleted and onError are intentionally ommited, as we don't want to
// retrigger the fetching, if someone forgot to memoize it
}, [ ...deps, skip, fetcherFn ]);
return [ resource, { mutate, refetch, abort } ];
}
function isAbortError(e: any): e is { name: "AbortError" } {
// We can't really check if it's an instanceof DOMException as it doesn't
// exist in older node version, and we can't check if it's an instanceof
// Error, as jsdom implementation of DOMException isn't an instance of it.
return e != null && e.name === "AbortError";
}