Async hooks: useSinglerunAction, useQueuedAction, and more

Async hooks give you a consistent way to handle asynchronous operations in your components. Each hook returns a loading boolean and an error boolean alongside an execute function, so you can drive UI state without writing boilerplate. Depending on which hook you choose, you get additional behavior: single-execution guards, ordered queuing, batch progress tracking, or a shared semaphore that disables multiple buttons at once.

Wraps an async function so that only one invocation can run at a time. If you call execute while a previous call is still pending, the new call is a no-op until the first one settles. This is useful for upload buttons and any action where duplicate submissions would cause problems.

function useSinglerunAction<Data, Payload>(
run: (payload: Payload) => Data | Promise<Data>,
options?: {
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
fallback?: (e: Error) => void;
throwError?: boolean;
}
): {
execute: (payload?: Payload) => Promise<Data | null>;
loading: boolean;
error: boolean;
}

run (required)(payload: Payload) => Data | Promise<Data>

The async function to execute. Receives an optional payload argument and must return the result or a Promise of the result.

options.onLoadStart() => void

Called immediately before the action begins executing. Use this to show a global loader.

options.onLoadEnd(isOk: boolean) => void

Called after the action completes or fails. isOk is false when an error was thrown.

options.fallback(e: Error) => void

Called with the caught error when the action throws and throwError is false (the default). Use this to display an error toast.

options.throwErrorboolean (default: false)

When true, errors thrown inside run are re-thrown from execute instead of being passed to fallback.

Property Type Description
execute (payload?) => Promise<Data | null> Triggers the action. Concurrent calls while loading are silently dropped. Call execute.clear() to reset the single-run lock.
loading boolean true while the action is running.
error boolean true if the most recent invocation threw an error.
import { useSinglerunAction } from 'react-declarative';

function UploadButton({ onChange }: { onChange: (path: string) => void }) {
const { execute, loading } = useSinglerunAction(async () => {
const file = await chooseFile('image/jpeg,image/png');
if (file) {
const filePath = await uploadService.upload(file);
onChange(filePath);
}
});

return (
<button onClick={execute} disabled={loading}>
{loading ? 'Uploading…' : 'Upload image'}
</button>
);
}

Runs every call in the order it was received — earlier calls must complete before later ones start. Unlike useSinglerunAction, no calls are dropped: they queue up and execute sequentially. This is ideal for real-time state-reducer patterns such as processing WebSocket events in order.

function useQueuedAction<Data, Payload>(
run: (payload: Payload) => Data | Promise<Data>,
options?: {
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
fallback?: (e: Error) => void;
throwError?: boolean;
}
): {
execute: (payload?: Payload) => Promise<Data | null>;
loading: boolean;
error: boolean;
}

run (required)(payload: Payload) => Data | Promise<Data>

The async function to execute for each queued item.

options.onLoadStart() => void

Called before each queued execution begins.

options.onLoadEnd(isOk: boolean) => void

Called after each queued execution completes or fails.

options.fallback(e: Error) => void

Error handler when throwError is false.

options.throwErrorboolean (default: false)

Re-throws errors from execute instead of routing them to fallback.

Property Type Description
execute (payload?) => Promise<Data | null> Enqueues a call. Call execute.cancel() to abandon the current item and execute.clear() to drain the entire queue.
loading boolean true while any item is being processed.
error boolean true if the most recent item threw.
import { useEffect } from 'react';
import { useQueuedAction } from 'react-declarative';

function KanbanBoard() {
const { execute } = useQueuedAction(
async ({ type, payload }) => {
if (type === 'create') await api.createCard(payload);
if (type === 'update') await api.updateCard(payload);
if (type === 'remove') await api.removeCard(payload);
},
{
onLoadStart: () => setAppbarLoader(true),
onLoadEnd: () => setAppbarLoader(false),
}
);

useEffect(() => kanbanService.createSubject.subscribe(execute), []);
useEffect(() => kanbanService.updateSubject.subscribe(execute), []);
useEffect(() => kanbanService.removeSubject.subscribe(execute), []);

return <Board />;
}

The foundational async hook. Every call to execute cancels the previous in-flight call and starts a fresh one. Use this when you want the latest request to always win — for example, live search or on-demand data loads triggered by user interactions.

function useAsyncAction<Data, Payload>(
run: (payload: Payload) => Data | Promise<Data>,
options?: {
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
fallback?: (e: Error) => void;
throwError?: boolean;
}
): {
execute: (payload?: Payload) => Promise<Data | null>;
loading: boolean;
error: boolean;
}

run (required)(payload: Payload) => Data | Promise<Data>

The async function to run. The previous invocation is cancelled when a new execute call arrives.

options.onLoadStart() => void

Called before each execution.

options.onLoadEnd(isOk: boolean) => void

Called after each execution.

options.fallback(e: Error) => void

Error handler when throwError is false.

options.throwErrorboolean (default: false)

Re-throws errors from execute.

Property Type Description
execute (payload?) => Promise<Data | null> Triggers the action, cancelling any previous pending call.
loading boolean true while the current call is running.
error boolean true if the most recent call threw.
import { useAsyncAction } from 'react-declarative';

function SaveButton({ data }: { data: FormData }) {
const { execute, loading, error } = useAsyncAction(
async (formData: FormData) => {
await api.save(formData);
},
{
fallback: (e) => toast.error(e.message),
}
);

return (
<>
{error && <span>Save failed — please try again.</span>}
<button onClick={() => execute(data)} disabled={loading}>
Save
</button>
</>
);
}

Processes a batch of items sequentially while tracking progress as a percentage (0–100). Each item in the array is processed one at a time; the hook exposes the current progress value and any per-item errors that accumulated. Feed the progress value directly into a <LinearProgress /> component.

interface IProcess<Data> {
label: string; // human-readable identifier shown while processing
data: Data; // the payload passed to your process function
}

function useAsyncProgress<Data, Result = void>(
process: (item: IProcess<Data>) => Result | Promise<Result>,
options?: {
delay?: number;
onBegin?: () => void;
onEnd?: (isOk: boolean) => void;
onFinish?: (
data: Data[],
errors: { label: string; message: string; error: Error }[],
results: (Result | null)[]
) => void;
onError?: (errors: { label: string; message: string; error: Error }[]) => void | boolean;
onProgress?: (progress: number) => void;
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
}
): {
execute: (items: IProcess<Data>[]) => void;
loading: boolean;
progress: number;
errors: { label: string; message: string; error: Error }[];
label: string;
}

process (required)(item: { label: string; data: Data }) => Result | Promise<Result>

Called once per item. Each item has a human-readable label (used in error reporting) and the data object to process.

options.delaynumber (default: 0)

Minimum delay in milliseconds to hold on each item before advancing. Useful for giving the UI time to render progress updates.

options.onBegin() => void

Called once before the first item is processed.

options.onEnd(isOk: boolean) => void

Called once after all items are processed (or after an early abort). isOk is false if any item errored.

options.onFinish(data, errors, results) => void

Called with the full input data array, all accumulated errors, and the result array after processing finishes.

options.onError(errors) => void | boolean

Called when an item throws. Return false to abort the remaining items; return true or undefined to continue processing.

options.onProgress(progress: number) => void

Called after each item with the updated percentage (0–100).

Property Type Description
execute (items: IProcess<Data>[]) => void Starts processing the batch. Concurrent calls are blocked (single-run semantics).
loading boolean true while the batch is running.
progress number Current percentage, 0–100.
errors array Items that threw, each with label, message, and error.
label string The label of the item currently being processed.
import { useState } from 'react';
import { useAsyncProgress } from 'react-declarative';
import LinearProgress from '@mui/material/LinearProgress';

function ImportContacts() {
const [progress, setProgress] = useState(0);

const { execute, loading } = useAsyncProgress(
async ({ data }) => {
await api.createContact(data);
},
{
onProgress: setProgress,
onError: (errors) => {
console.error('Some rows failed:', errors);
return true; // continue remaining rows
},
onEnd: (isOk) => navigate(isOk ? '/success' : '/report'),
}
);

const handleFileSelect = async () => {
const file = await chooseFile('.xlsx');
if (file) {
const rows = await parseExcel(file);
execute(rows.map((row, i) => ({ label: `Row ${i + 1}`, data: row })));
}
};

return (
<>
{loading && <LinearProgress variant="determinate" value={progress} />}
<button onClick={handleFileSelect} disabled={loading}>
Import XLSX
</button>
</>
);
}

Fetches data asynchronously on mount (and whenever deps change) and stores the result in local state. It returns the current value, an action object for manual re-fetching, a setter for optimistic updates, and utility helpers. Think of it as useState combined with useEffect for async data.

function useAsyncValue<Data>(
run: () => Data | Promise<Data>,
options?: {
deps?: any[];
fallback?: (e: Error) => void;
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
throwError?: boolean;
}
): [
Data | null, // [0] current value
{ execute: () => Promise<void>; loading: boolean; error: boolean }, // [1] action controls
(data: Data) => void, // [2] optimistic setter
{ waitForResult: () => Promise<Data>; data$: MutableRefObject<Data | null> } // [3] utilities
]

run (required)() => Data | Promise<Data>

Factory function called on mount and whenever deps change. Takes no arguments; close over variables you need.

options.depsany[] (default: [])

Dependency array passed to the internal useEffect. When any dep changes, run is called again.

options.fallback(e: Error) => void

Error handler when throwError is false.

options.onLoadStart() => void

Called before each fetch.

options.onLoadEnd(isOk: boolean) => void

Called after each fetch.

Index Type Description
[0] Data | null The current fetched value, or null before the first load.
[1] { execute, loading, error } Manual re-fetch control. Call execute() to trigger a new fetch.
[2] (data: Data) => void Setter for optimistic updates — replaces the stored value without a network call.
[3].waitForResult () => Promise<Data> Resolves when a non-null value becomes available.
[3].data$ MutableRefObject<Data | null> A ref to the current value for use in callbacks without closure staleness.
import { useAsyncValue } from 'react-declarative';

function UserProfile({ userId }: { userId: string }) {
const [user, { loading, error }, setUser] = useAsyncValue(
async () => api.getUser(userId),
{ deps: [userId] }
);

if (loading) return <Spinner />;
if (error || !user) return <ErrorMessage />;

const handleRename = async (name: string) => {
const updated = await api.updateUser(userId, { name });
setUser(updated); // optimistic update — no refetch
};

return <UserCard user={user} onRename={handleRename} />;
}

A counting semaphore that tracks how many async operations are currently in flight. Pass handleLoadStart and handleLoadEnd as onLoadStart/onLoadEnd props to multiple buttons, and they all share a single loading flag. While any one is running, the loading boolean is true, so you can disable them all simultaneously.

function usePreventAction(options?: {
disabled?: boolean;
onLoadStart?: () => void;
onLoadEnd?: (isOk: boolean) => void;
}): {
handleLoadStart: () => void;
handleLoadEnd: (isOk: boolean) => void;
loading: boolean;
}

options.disabledboolean

When true, forces loading to true regardless of in-flight operations. Useful for propagating an external disabled state.

options.onLoadStart() => void

Forwarded callback called each time any tracked operation starts.

options.onLoadEnd(isOk: boolean) => void

Forwarded callback called each time any tracked operation ends.

Property Type Description
handleLoadStart () => void Increments the internal counter. Pass as onLoadStart to each ActionButton.
handleLoadEnd (isOk: boolean) => void Decrements the counter. Pass as onLoadEnd to each ActionButton.
loading boolean true when the counter is greater than zero, or when disabled is true.
import { usePreventAction, ActionButton } from 'react-declarative';

function ToolBar() {
const { handleLoadStart, handleLoadEnd, loading } = usePreventAction();

return (
<>
<ActionButton
disabled={loading}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onClick={async () => api.doAction1()}
>
Action 1
</ActionButton>

<ActionButton
disabled={loading}
onLoadStart={handleLoadStart}
onLoadEnd={handleLoadEnd}
onClick={async () => api.doAction2()}
>
Action 2
</ActionButton>
</>
);
}