alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
531 lines (479 loc) • 15 kB
text/typescript
import type { Async } from "alepha";
import {
DateTimeProvider,
type DurationLike,
type Interval,
type Timeout,
} from "alepha/datetime";
import {
type DependencyList,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useAlepha } from "./useAlepha.ts";
import { useInject } from "./useInject.ts";
/**
* Hook for handling async actions with automatic error handling and event emission.
*
* By default, prevents concurrent executions - if an action is running and you call it again,
* the second call will be ignored. Use `debounce` option to delay execution instead.
*
* Emits lifecycle events:
* - `react:action:begin` - When action starts
* - `react:action:success` - When action completes successfully
* - `react:action:error` - When action throws an error
* - `react:action:end` - Always emitted at the end
*
* @example Basic usage
* ```tsx
* const action = useAction({
* handler: async (data) => {
* await api.save(data);
* }
* }, []);
*
* <button onClick={() => action.run(data)} disabled={action.loading}>
* Save
* </button>
* ```
*
* @example With debounce (search input)
* ```tsx
* const search = useAction({
* handler: async (query: string) => {
* await api.search(query);
* },
* debounce: 300 // Wait 300ms after last call
* }, []);
*
* <input onChange={(e) => search.run(e.target.value)} />
* ```
*
* @example Run on component mount
* ```tsx
* const fetchData = useAction({
* handler: async () => {
* const data = await api.getData();
* return data;
* },
* runOnInit: true // Runs once when component mounts
* }, []);
* ```
*
* @example Run periodically (polling)
* ```tsx
* const pollStatus = useAction({
* handler: async () => {
* const status = await api.getStatus();
* return status;
* },
* runEvery: 5000 // Run every 5 seconds
* }, []);
*
* // Or with duration tuple
* const pollStatus = useAction({
* handler: async () => {
* const status = await api.getStatus();
* return status;
* },
* runEvery: [30, 'seconds'] // Run every 30 seconds
* }, []);
* ```
*
* @example With AbortController
* ```tsx
* const fetch = useAction({
* handler: async (url, { signal }) => {
* const response = await fetch(url, { signal });
* return response.json();
* }
* }, []);
* // Automatically cancelled on unmount or when new request starts
* ```
*
* @example With error handling
* ```tsx
* const deleteAction = useAction({
* handler: async (id: string) => {
* await api.delete(id);
* },
* onError: (error) => {
* if (error.code === 'NOT_FOUND') {
* // Custom error handling
* }
* }
* }, []);
*
* {deleteAction.error && <div>Error: {deleteAction.error.message}</div>}
* ```
*
* @example Global error handling
* ```tsx
* // In your root app setup
* alepha.events.on("react:action:error", ({ error }) => {
* toast.danger(error.message);
* Sentry.captureException(error);
* });
* ```
*/
export function useAction<Args extends any[], Result = void>(
options: UseActionOptions<Args, Result>,
deps: DependencyList,
): UseActionReturn<Args, Result> {
const alepha = useAlepha();
const dateTimeProvider = useInject(DateTimeProvider);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const [result, setResult] = useState<Result | undefined>();
const isExecutingRef = useRef(false);
const debounceTimerRef = useRef<Timeout | undefined>(undefined);
const abortControllerRef = useRef<AbortController | undefined>(undefined);
const isMountedRef = useRef(true);
const intervalRef = useRef<Interval | undefined>(undefined);
// Monotonic id of the latest run. A superseded run keeps executing until its
// abort lands, so every state write is gated on "am I still the latest?" —
// otherwise the stale run's `finally` would clear the newer run's `loading`.
const runIdRef = useRef(0);
// Track mount state — must set true in body for React StrictMode double-invoke
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// clear debounce timer
if (debounceTimerRef.current) {
dateTimeProvider.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = undefined;
}
// clear interval
if (intervalRef.current) {
dateTimeProvider.clearInterval(intervalRef.current);
intervalRef.current = undefined;
}
// abort in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = undefined;
}
// Release the concurrency guard: we've just abandoned the in-flight run
// (its result is discarded by the aborted-signal check), so the *next*
// lifecycle must be free to start. Without this, a remount that reuses
// the same refs — React StrictMode's double-invoke, or a Suspense/router
// remount on client-side navigation — would hit `if (isExecutingRef
// .current) return` and never re-run, leaving a `useQuery` stuck on its
// loading skeleton forever (the original request 200s but is dropped).
isExecutingRef.current = false;
};
}, []);
const executeAction = useCallback(
async (
args: Args,
{ supersede = false }: { supersede?: boolean } = {},
): Promise<Result | undefined> => {
if (isExecutingRef.current && !supersede) {
// A manual `run()` must not fire twice — this is what stops a
// double-clicked mutation from being submitted twice.
return;
}
// A dep-change or interval run supersedes: the in-flight request was
// issued for inputs that are now stale, so its result must never win.
// (This is why the guard above is skipped rather than short-circuiting —
// otherwise `useQuery([userId])` whose `userId` changes mid-flight would
// never refetch, and the old user's data would stay on screen.)
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
const runId = ++runIdRef.current;
const isLatestRun = () => runIdRef.current === runId;
isExecutingRef.current = true;
setLoading(true);
setError(undefined);
try {
await alepha.events.emit("react:action:begin", {
type: "custom",
id: options.id,
});
// Pass abort signal as last argument to handler
const result = await options.handler(...args, {
signal: abortController.signal,
} as any);
// Only update state if still mounted, not aborted, and not superseded
if (
!isMountedRef.current ||
abortController.signal.aborted ||
!isLatestRun()
) {
return;
}
setResult(result as Result);
await alepha.events.emit("react:action:success", {
type: "custom",
id: options.id,
});
if (options.onSuccess) {
await options.onSuccess(result);
}
return result;
} catch (err) {
// Ignore abort errors
if (err instanceof Error && err.name === "AbortError") {
return;
}
// Only update state if still mounted and not superseded
if (!isMountedRef.current || !isLatestRun()) {
return;
}
const error = err as Error;
setError(error);
await alepha.events.emit("react:action:error", {
type: "custom",
id: options.id,
error,
});
if (options.onError) {
await options.onError(error);
}
// Without a custom `onError`, the error is NOT re-thrown: it is captured
// in `error` state and emitted as `react:action:error` (a mounted
// <ActionErrorToaster /> surfaces it as a toast). This keeps
// fire-and-forget `action.run()` calls from producing unhandled
// promise rejections.
} finally {
// A superseded run must not release the guard or drop `loading` — the
// run that replaced it is still in flight.
if (isLatestRun()) {
isExecutingRef.current = false;
if (isMountedRef.current) {
setLoading(false);
}
}
await alepha.events.emit("react:action:end", {
type: "custom",
id: options.id,
});
// Clean up abort controller
if (abortControllerRef.current === abortController) {
abortControllerRef.current = undefined;
}
}
},
[...deps, options.id, options.onError, options.onSuccess],
);
const runAction = useCallback(
async (
args: Args,
options_: { supersede?: boolean } = {},
): Promise<Result | undefined> => {
if (options.debounce) {
// clear existing timer
if (debounceTimerRef.current) {
dateTimeProvider.clearTimeout(debounceTimerRef.current);
}
// Set new timer
return new Promise((resolve) => {
debounceTimerRef.current = dateTimeProvider.createTimeout(
async () => {
const result = await executeAction(args, options_);
resolve(result);
},
options.debounce ?? 0,
);
});
}
return executeAction(args, options_);
},
[executeAction, options.debounce],
);
/**
* Public `run()` — a user-initiated call. Deduped while one is in flight so a
* double-clicked mutation submits once.
*/
const handler = useCallback(
(...args: Args): Promise<Result | undefined> => runAction(args),
[runAction],
);
const cancel = useCallback(() => {
// clear debounce timer
if (debounceTimerRef.current) {
dateTimeProvider.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = undefined;
}
// abort in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = undefined;
}
// reset state
if (isMountedRef.current) {
isExecutingRef.current = false;
setLoading(false);
}
}, []);
// Run action on mount, and again whenever `deps` change. These runs supersede
// an in-flight request: it was issued for the previous deps, so its result is
// already stale.
useEffect(() => {
if (options.runOnInit) {
runAction([] as any, { supersede: true });
}
}, deps);
// Run action periodically if runEvery is specified
useEffect(() => {
if (!options.runEvery) {
return;
}
// Set up interval
intervalRef.current = dateTimeProvider.createInterval(
() => runAction([] as any, { supersede: true }),
options.runEvery,
true,
);
// cleanup on unmount or when runEvery changes
return () => {
if (intervalRef.current) {
dateTimeProvider.clearInterval(intervalRef.current);
intervalRef.current = undefined;
}
};
}, [runAction, options.runEvery]);
return {
run: handler,
loading,
error,
cancel,
result,
};
}
// ---------------------------------------------------------------------------------------------------------------------
/**
* Context object passed as the last argument to action handlers.
* Contains an AbortSignal that can be used to cancel the request.
*/
export interface ActionContext {
/**
* AbortSignal that can be passed to fetch or other async operations.
* The signal will be aborted when:
* - The component unmounts
* - A new action is triggered (cancels previous)
* - The cancel() method is called
*
* @example
* ```tsx
* const action = useAction({
* handler: async (url, { signal }) => {
* const response = await fetch(url, { signal });
* return response.json();
* }
* }, []);
* ```
*/
signal: AbortSignal;
}
export interface UseActionOptions<Args extends any[] = any[], Result = any> {
/**
* The async action handler function.
* Receives the action arguments plus an ActionContext as the last parameter.
*/
handler: (...args: [...Args, ActionContext]) => Async<Result>;
/**
* Custom error handler. If provided, prevents default error re-throw.
*/
onError?: (error: Error) => void | Promise<void>;
/**
* Custom success handler.
*/
onSuccess?: (result: Result) => void | Promise<void>;
/**
* Optional identifier for this action (useful for debugging/analytics)
*/
id?: string;
name?: string;
/**
* Debounce delay in milliseconds. If specified, the action will only execute
* after the specified delay has passed since the last call. Useful for search inputs
* or other high-frequency events.
*
* @example
* ```tsx
* // Execute search 300ms after user stops typing
* const search = useAction({ handler: search, debounce: 300 }, [])
* ```
*/
debounce?: number;
/**
* If true, the action will be executed once when the component mounts.
*
* @example
* ```tsx
* const fetchData = useAction({
* handler: async () => await api.getData(),
* runOnInit: true
* }, []);
* ```
*/
runOnInit?: boolean;
/**
* If specified, the action will be executed periodically at the given interval.
* The interval is specified as a DurationLike value (number in ms, Duration object, or [number, unit] tuple).
*
* @example
* ```tsx
* // Run every 5 seconds
* const poll = useAction({
* handler: async () => await api.poll(),
* runEvery: 5000
* }, []);
* ```
*
* @example
* ```tsx
* // Run every 1 minute
* const poll = useAction({
* handler: async () => await api.poll(),
* runEvery: [1, 'minute']
* }, []);
* ```
*/
runEvery?: DurationLike;
}
export interface UseActionReturn<Args extends any[], Result> {
/**
* Execute the action with the provided arguments.
*
* @example
* ```tsx
* const action = useAction({ handler: async (data) => { ... } }, []);
* action.run(data);
* ```
*/
run: (...args: Args) => Promise<Result | undefined>;
/**
* Loading state - true when action is executing.
*/
loading: boolean;
/**
* Error state - contains error if action failed, undefined otherwise.
*/
error?: Error;
/**
* Cancel any pending debounced action or abort the current in-flight request.
*
* @example
* ```tsx
* const action = useAction({ ... }, []);
*
* <button onClick={action.cancel} disabled={!action.loading}>
* Cancel
* </button>
* ```
*/
cancel: () => void;
/**
* The result data from the last successful action execution.
*/
result?: Result;
}