Use Async
A hook that owns the async action lifecycle — loading state, success, error, and settled callbacks
src/registry/base/use-async/hooks/use-async.ts
import { useCallback, useEffect, useRef, useState } from "react";
export type AsyncStatus = "idle" | "pending" | "success" | "error";
/**
* Discriminated union for every possible state of an async operation.
* Narrow on `status` (or the boolean flags) to get typed `data` / `error` access.
*/
export type AsyncState<TData, TError> =
| {
status: "idle";
data: undefined;
error: undefined;
isIdle: true;
isLoading: false;
isSuccess: false;
isError: false;
}
| {
status: "pending";
data: TData | undefined; // preserves previous data while refetching
error: undefined;
isIdle: false;
isLoading: true;
isSuccess: false;
isError: false;
}
| {
status: "success";
data: TData;
error: undefined;
isIdle: false;
isLoading: false;
isSuccess: true;
isError: false;
}
| {
status: "error";
data: undefined;
error: TError;
isIdle: false;
isLoading: false;
isSuccess: false;
isError: true;
};
export type UseAsyncOptions<TData, TError, TArgs extends unknown[]> = {
/** The async function to run. Receives the same args passed to `execute`. */
action: (...args: TArgs) => Promise<TData> | TData;
/** Called with the resolved value and original args on success. */
onSuccess?: (data: TData, args: TArgs) => void;
/** Called with the thrown error and original args on failure. */
onError?: (error: TError, args: TArgs) => void;
/** Called after every settled attempt regardless of outcome. */
onSettled?: (
data: TData | undefined,
error: TError | undefined,
args: TArgs,
) => void;
};
export type UseAsyncReturn<TData, TError, TArgs extends unknown[]> = AsyncState<
TData,
TError
> & {
/**
* Run the action and await the result , even though side effect can be dispatch with onError ,
* but it will **Reject on failure** for imperative error handling
* Use when the next step depends on the outcome (sequencing , guarded , navigation) or your own
* try/catch , Mirrors Tanstack `mutateAsync`
*/
executeAsync: (...args: TArgs) => Promise<TData>;
/**
* Fire and Forget , **It never reject** , error handling can be listen declaratively with
* sideEfect with onError
* Use when the action is the end of the story (e.g a button that just shows success/error).
* Mirror Tanstack `mutate`
*/
execute: (...args: TArgs) => void;
reset: () => void;
};
const IDLE_STATE = {
status: "idle",
data: undefined,
error: undefined,
isIdle: true,
isLoading: false,
isSuccess: false,
isError: false,
} as const;
/**
* Wraps an async function with loading, success, and error state.
*
* - Stale responses are dropped — only the most-recent `execute` call can commit
* state. Calling `execute` again while in-flight silently cancels the earlier one.
* - Callbacks are always fresh; no stale-closure risk without wrapping in `useCallback`.
* - `reset` cancels any in-flight request and returns to the `idle` state.
*/
export function useAsync<
TData = unknown,
TError = unknown,
TArgs extends unknown[] = [],
>(
options: UseAsyncOptions<TData, TError, TArgs>,
): UseAsyncReturn<TData, TError, TArgs> {
const [state, setState] = useState<AsyncState<TData, TError>>(IDLE_STATE);
// always fresh callbacks without forcing execute to change identity (deps array)
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
// latest wins , stale response will dropped
const requestIdRef = useRef(0);
// prevent setState after mounted
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const executeAsync = useCallback(async (...args: TArgs): Promise<TData> => {
// Tăng trước khi đọc
const requestId = ++requestIdRef.current;
const { action, onSuccess, onError, onSettled } = optionsRef.current;
setState((prev) => ({
status: "pending",
data: prev.data,
error: undefined,
isIdle: false,
isLoading: true,
isSuccess: false,
isError: false,
}));
try {
const data = await action(...args);
if (requestId === requestIdRef.current && mountedRef.current) {
// commit state only on the latest && component still mounted
setState({
status: "success",
data,
error: undefined,
isIdle: false,
isLoading: false,
isSuccess: true,
isError: false,
});
onSuccess?.(data, args);
onSettled?.(data, undefined, args);
}
return data;
} catch (err) {
const error = err as TError;
if (requestId === requestIdRef.current && mountedRef.current) {
setState({
status: "error",
data: undefined,
error,
isIdle: false,
isLoading: false,
isSuccess: false,
isError: true,
});
onError?.(error, args);
onSettled?.(undefined, error, args);
}
throw err;
}
}, []);
const execute = useCallback(async (...args: TArgs) => {
// Fire-and-forget: errors are already committed to state and passed to
// onError inside executeAsync, so we swallow the rejection here to spare
// callers from having to .catch. This is what makes execute "never reject".
try {
const data = await executeAsync(...args);
return data;
} catch {}
}, []);
const reset = useCallback(() => {
requestIdRef.current++;
if (mountedRef.current) {
setState(IDLE_STATE);
}
}, []);
return { ...state, execute, executeAsync, reset };
}
- Where it goes
- stats
- Interaction
- animated-counter