Skip to content

useAsyncTask

Edit

Runs abort-aware work on demand and exposes the state of its latest invocation.

useAsyncTask.ts
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 };
};
Terminal
wget -O src/hooks/useAsyncTask.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useAsyncTask.ts
useAsyncTask.example.tsx
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 to run.
  • options.abortPrevious (boolean, default: true) — Aborts active earlier invocations when a new invocation starts.

An object containing:

  • state — A discriminated idle, pending, success, or error state. Successful data is Awaited<R>.
  • run(...args) — Starts the task and returns Promise<Awaited<R>>.
  • abort() — Aborts every active invocation and returns the state to idle.

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.