useAction
Hook for registering and executing actions.
Signature
function useAction<TInput, TResult>(
options: ActionOptionsConfig<TInput, TResult>,
callbacks?: UseActionCallbacks<TResult>,
): {
execute: (input: TInput) => void;
executeAsync: (input: TInput) => Promise<
| { enqueued: true; jobId: string }
| { enqueued: false; result: TResult }
>;
pendingCount: number;
lastError: unknown;
};Parameters
options
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
actionKey | string | ✅ | — | Unique action identifier |
request | (input: TInput) => Promise<TResult> | ✅ | — | Server request function. TInput and TResult inferred from here |
whenOffline | 'queue' | 'fail' | 'queue' | Behavior when offline | |
retry | RetryPolicy | — | Retry policy | |
flushOption | FlushOption | — | Concurrent execution control during flush | |
dedupeKey | (input: TInput) => string | — | Entity identifier for deduplication | |
dedupeOnFlush | 'keep-first' | 'keep-last' | — | Flush-time dedupe strategy |
Can be extracted with actionOptions() or passed inline. Type inference works either way.
callbacks
| Callback | Type | When |
|---|---|---|
onSuccess | (result: TResult) => void | Immediate execution succeeded. result inferred from request return type |
onEnqueued | (jobId: string) => void | Queued (offline or hasRunningDupe) |
onError | (error: unknown) => void | Execution failed. Called as a side-effect before the error propagates |
onSettled | () => void | Called after direct execution (success or error). When queued, called after the job flushes (success or final failure) — not at enqueue time |
Callbacks are synced on every render internally — inline functions are safe, no stale closures.
Returns
| Field | Type | Description |
|---|---|---|
execute | (input: TInput) => void | Fire-and-forget execution. Errors go to onError. Stable reference |
executeAsync | (input: TInput) => Promise<...> | Awaitable execution. Always throws on error (after calling onError). Stable reference |
pendingCount | number | queued + running job count for this action |
lastError | unknown | Error from the most recent failed job |
execute vs executeAsync
Follows the same pattern as React Query’s mutate / mutateAsync:
// Fire-and-forget — errors handled by onError callback
execute(input);
// Awaitable — returns discriminated union, always throws on error
const result = await executeAsync(input);
if (result.enqueued) {
result.jobId; // string ✅
result.result; // compile error ❌
}
if (!result.enqueued) {
result.result; // TResult ✅
result.jobId; // compile error ❌
}Examples
Basic
const { execute } = useAction({
actionKey: 'save',
request: (input: { id: string; data: string }) => api.save(input),
});
execute({ id: '1', data: 'hello' }); // fire-and-forget, input type inferredReusable action
// actions/save.ts
export const saveAction = actionOptions({
actionKey: 'save',
request: (input: { id: string; data: string }) => api.save(input),
dedupeKey: (input) => input.id,
});
// component.tsx
const { execute } = useAction(saveAction, {
onSuccess: (result) => toast.success('Saved'),
});All callbacks
const { execute, pendingCount, lastError } = useAction(
{
actionKey: 'upload',
request: (input: { file: File }) => api.upload(input),
whenOffline: 'fail',
},
{
onSuccess: (result) => showPreview(result.url),
onError: (error) => reportError(error),
onSettled: () => hideSpinner(),
},
);pendingCount
function SaveIndicator() {
const { execute, pendingCount } = useAction(saveAction);
return (
<div>
<button onClick={() => execute({ id: '1', data: 'hello' })}>Save</button>
{pendingCount > 0 && <Spinner />}
</div>
);
}Under the Hood
useAction creates an ActionObserver instance internally:
useState(() => new ActionObserver(client, options, defaults.actions))— one per hook, stableuseEffect(() => observer.setOptions(options, defaults.actions))— in-place update on options or global defaults changeobserver.setCallbacks(callbacks)— synced every render (stale closure prevention)useSyncExternalStore(observer.subscribe, observer.getCurrentResult)— queue subscriptionobserver.execute(input)(void) /observer.executeAsync(input)(Promise) — delegates toConnectivityClient+ invokes callbacks
getCurrentResult() memoizes the return value. Only creates a new reference when pendingCount or lastError actually changes.
Related
Last updated on