useAsyncTask
EditRuns abort-aware work on demand and exposes the state of its latest invocation.
import { useCallback, useEffect, useLayoutEffect, useRef, useState,} from "react";
export type AsyncTaskState<Result> = | { status: "idle"; data: undefined; error: undefined; } | { status: "pending"; data: undefined; error: undefined; } | { status: "success"; data: Result; error: undefined; } | { status: "error"; data: undefined; error: unknown; };
export type AsyncTask<Arguments extends unknown[], Result> = ( this: void, signal: AbortSignal, ...args: Arguments) => Result | PromiseLike<Result>;
export type UseAsyncTaskOptions = { abortPrevious?: boolean;};
export type UseAsyncTaskReturn<Arguments extends unknown[], Result> = { state: AsyncTaskState<Result>; run: (this: void, ...args: Arguments) => Promise<Result>; abort: () => void;};
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
const idleState = (): AsyncTaskState<never> => ({ status: "idle", data: undefined, error: undefined,});
/** * Runs an abort-aware task on demand and exposes the state of its latest run. */export const useAsyncTask = <Arguments extends unknown[], Result>( task: AsyncTask<Arguments, Result>, options: UseAsyncTaskOptions = {},): UseAsyncTaskReturn<Arguments, Awaited<Result>> => { const [state, setState] = useState<AsyncTaskState<Awaited<Result>>>( idleState, ); const taskRef = useRef(task); const abortPreviousRef = useRef(options.abortPrevious ?? true); const activeControllersRef = useRef(new Set<AbortController>()); const runIdRef = useRef(0); const mountedRef = useRef(false);
useIsomorphicLayoutEffect(() => { taskRef.current = task; abortPreviousRef.current = options.abortPrevious ?? true; }, [options.abortPrevious, task]);
useIsomorphicLayoutEffect(() => { mountedRef.current = true;
return () => { mountedRef.current = false; runIdRef.current += 1; const activeControllers = [...activeControllersRef.current]; activeControllersRef.current.clear(); for (const controller of activeControllers) { controller.abort(); } }; }, []);
const abort = useCallback(() => { runIdRef.current += 1; const activeControllers = [...activeControllersRef.current]; activeControllersRef.current.clear(); if (mountedRef.current) setState(idleState); for (const controller of activeControllers) { controller.abort(); } }, []);
const run = useCallback( async (...args: Arguments): Promise<Awaited<Result>> => { if (!mountedRef.current) { throw new Error("Cannot run a task after its hook has unmounted"); }
if (abortPreviousRef.current) { const activeControllers = [...activeControllersRef.current]; activeControllersRef.current.clear(); for (const activeController of activeControllers) { activeController.abort(); } }
const controller = new AbortController(); const runId = runIdRef.current + 1; runIdRef.current = runId; activeControllersRef.current.add(controller);
if (mountedRef.current) { setState({ status: "pending", data: undefined, error: undefined, }); }
try { const taskResult = Reflect.apply(taskRef.current, undefined, [ controller.signal, ...args, ]) as Result | PromiseLike<Result>; const data = await taskResult;
if (mountedRef.current && runIdRef.current === runId) { setState({ status: "success", data, error: undefined, }); }
return data; } catch (error) { if (mountedRef.current && runIdRef.current === runId) { setState({ status: "error", data: undefined, error, }); }
throw error; } finally { activeControllersRef.current.delete(controller); } }, [], );
return { state, run, abort };};Download
Section titled “Download”wget -O src/hooks/useAsyncTask.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useAsyncTask.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/react-hooks "$temp_dir" && mv "$temp_dir/useAsyncTask.ts" src/hooks/useAsyncTask.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { useAsyncTask } from "./useAsyncTask";
export const UserLookup = () => { const lookup = useAsyncTask(async (signal, userId: number) => { const response = await fetch(`/api/users/${userId}`, { signal }); if (!response.ok) throw new Error("Could not load the user"); return response.json() as Promise<{ name: string }>; });
return ( <section> <button type="button" onClick={() => void lookup.run(42)}> Load user </button> {lookup.state.status === "pending" && <p>Loading…</p>} {lookup.state.status === "success" && <p>{lookup.state.data.name}</p>} {lookup.state.status === "error" && <p>Loading failed.</p>} </section> );};task((signal: AbortSignal, ...args: A) => R | PromiseLike<R>) — Work to run. It receives a distinct abort signal followed by the arguments passed torun.options.abortPrevious(boolean, default:true) — Aborts active earlier invocations when a new invocation starts.
Returns
Section titled “Returns”An object containing:
state— A discriminatedidle,pending,success, orerrorstate. Successful data isAwaited<R>.run(...args)— Starts the task and returnsPromise<Awaited<R>>.abort()— Aborts every active invocation and returns the state toidle.
A caller can start run from a layout effect. Only the latest invocation can update state, while earlier promises still settle for their callers. abort() and unmounting abort every active invocation.