Skip to Content
APIReactuseAction

useAction

Hook for registering and executing actions.

Signature

function useAction<TInput, TResult>( options: ActionOptionsConfig<TInput, TResult>, callbacks?: UseActionCallbacks<TResult>, ): { execute: (input: TInput) => Promise< | { enqueued: true; jobId: string } | { enqueued: false; result: TResult } | undefined >; pendingCount: number; lastError: unknown; };

Parameters

options

FieldTypeRequiredDefaultDescription
actionKeystringUnique action identifier
request(input: TInput) => Promise<TResult>Server request function. TInput and TResult inferred from here
whenOffline'queue' | 'fail''queue'Behavior when offline
retryRetryPolicyRetry policy
flushOptionFlushOptionConcurrent execution control during flush
dedupeKey(input: TInput) => stringEntity 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

CallbackTypeWhen
onSuccess(result: TResult) => voidImmediate execution succeeded. result inferred from request return type
onEnqueued(jobId: string) => voidQueued (offline or hasRunningDupe)
onError(error: unknown) => voidExecution failed. When provided, error is not re-thrown
onSettled() => voidCalled 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

FieldTypeDescription
execute(input: TInput) => Promise<...>Execute the action. Stable reference across renders
pendingCountnumberqueued + running job count for this action
lastErrorunknownError from the most recent failed job

execute return value

Discriminated union:

const result = await execute(input); // 1. Queued if (result.enqueued) { result.jobId; // string ✅ result.result; // compile error ❌ } // 2. Immediate success if (!result.enqueued) { result.result; // TResult ✅ result.jobId; // compile error ❌ } // 3. onError swallowed the error if (result === undefined) { // Handled by onError callback }

Examples

Basic

const { execute } = useAction({ actionKey: 'save', request: (input: { id: string; data: string }) => api.save(input), }); await execute({ id: '1', data: 'hello' }); // input type inferred

Reusable 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:

  1. useState(() => new ActionObserver(client, options, defaults.actions)) — one per hook, stable
  2. useEffect(() => observer.setOptions(options, defaults.actions)) — in-place update on options or global defaults change
  3. observer.setCallbacks(callbacks) — synced every render (stale closure prevention)
  4. useSyncExternalStore(observer.subscribe, observer.getCurrentResult) — queue subscription
  5. observer.execute(input) — delegates to ConnectivityClient + invokes callbacks

getCurrentResult() memoizes the return value. Only creates a new reference when pendingCount or lastError actually changes.

Last updated on