Skip to content
Twilight React Playground
ثيم رائدaren

useAsyncFn

hookAdvancedserverbrowserlive demo

Wraps an async function with loading, error and value state; only the newest call updates state, and nothing updates after unmount.

import { useAsyncFn, AsyncFnReturn, AsyncState } from '@salla.sa/twilight-theme-engine/hooks/useAsyncFn';

In plain words

When a button starts something slow, such as a request, you want a spinner while it runs and a message if it fails. const [state, run] = useAsyncFn(async () => …) gives you state.loading, state.error and state.value, and calling run() starts the work.

It is the building block of useCoupon, useWishlist and useBlogLike.

Signature

function useAsyncFn<T extends (...args: never[]) => Promise<unknown>>(
  fn: T,
  deps?: DependencyList,             // default []
  initialState?: AsyncState<Awaited<ReturnType<T>>>  // default { loading: false }
): AsyncFnReturn<T>;

type AsyncFnReturn<T> = [AsyncState<Awaited<ReturnType<T>>>, T];

type AsyncState<T> =
  | { loading: boolean; error?: undefined; value?: undefined }
  | { loading: true; error?: Error | undefined; value?: T }
  | { loading: false; error: Error; value?: undefined }
  | { loading: false; error?: undefined; value: T };

Try it live

useAsyncFn() wraps an async function and tracks loading, error and value. This one waits, then succeeds or throws.Try this: turn Fail on and run: await run() returns the Error instead of throwing. Then run both together: only Second reaches state.
Storefront canvas · ar · RTL
state: {…} 1 keys
loading: false
Controls
Fail
What a theme writes
import { useAsyncFn } from '@salla.sa/twilight-theme-engine/hooks/useAsyncFn';

export function SlowButton() {
  const [state, run] = useAsyncFn(async () => {
    await new Promise((resolve) => setTimeout(resolve, 1200));
    return 'Job finished';
  }, []);

  return (
    <>
      <button onClick={() => void run()} disabled={state.loading}>
        {state.loading ? 'Working…' : 'Run'}
      </button>
      {state.error && <p role="alert">{state.error.message}</p>}
      {state.value && <p>{state.value}</p>}
    </>
  );
}

Example

app/components/product/StockCheck.tsx
import { useAsyncFn } from '@salla.sa/twilight-theme-engine/hooks/useAsyncFn';
import { product } from '@salla.sa/twilight-theme-engine/api/product';

export function StockCheck({ productId }: { productId: number }) {
  const [state, check] = useAsyncFn(() => product.find(String(productId)), [productId]);

  return (
    <div>
      <button type="button" onClick={() => void check()} disabled={state.loading}>
        {state.loading ? 'Checking…' : 'Check stock'}
      </button>
      {state.error && <p role="alert">{state.error.message}</p>}
      {state.value && <p>{state.value.is_out_of_stock ? 'Out of stock' : 'In stock'}</p>}
    </div>
  );
}

How it behaves

  • Each call gets an id; a result is applied only if its call is still the newest and the component is still mounted.

  • Starting a call sets loading: true and keeps the previous value or error, so a stale error stays visible while the retry runs unless you hide it on loading.

  • The returned function is useCallback(fn, deps): deps is the only way new props or state reach fn.

  • For reading data, TanStack Query (the engine's api/* query options) caches and shares results; useAsyncFn suits one-off actions.

  • The @salla.sa/twilight-theme-engine/hooks barrel exports this state type as AsyncFnState, because AsyncState there is a different type (see Hook result types).

Gotchas

  • The wrapped function never rejects. On failure it resolves with whatever was thrown, so try { await run() } catch {} never catches. Check result instanceof Error, or read state.error.

  • deps defaults to [], so fn keeps the props and state of the first render unless you list them.

  • Whatever was thrown lands in state.error as it is, even when it is not an Error (a string, for example), despite the type.

Related

Source and docs