npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@samyx/react-async-hooks

v0.0.1

Published

React hooks that turn state-driven async flows into awaitable promises — fire-and-await imperative actions and observe state transitions.

Readme

@samyx/react-async-hooks

Two React hooks for coordinating imperative actions whose true completion shows up in state — not in a return value.

usePromiseCallback and useWatchState let you await things that React normally makes you chase through a tangle of useEffects and boolean flags.

// Fire an action and await the state it produces:
const wallet = await connectWallet('metamask');

// Or just observe a state transition:
await watchStatus('loading', 'success', { timeout: 10_000 });

Contents

The problem

You call an imperative action — requestConnection(walletId), submitPayment(...), player.play(). It returns void, or returns immediately while the real work continues. The result lands later as a state update: a value in an SDK hook, a context, a ref, the DOM.

Now you want to do something after that action has truly settled. Every naive approach breaks:

| Approach | Why it breaks | | --------------------------- | -------------------------------------------------------------------------------------- | | Read state on the next line | Stale closure — state hasn't updated in this call stack. | | Read a ref | Inconsistent; and it requires the source to expose a ref at all. | | Chain useEffects | Turns into a forest of effects and flags. Brittle, unreadable. | | An effect-based waiter | Hangs forever if the condition is already satisfied — no state change, no effect run. |

@tanstack/react-query hit exactly this wall and added mutateAsync to complement mutate. These two hooks are a generic version of that idea for any imperative action whose completion is reflected in state.

Installation

npm install @samyx/react-async-hooks
# or
bun add @samyx/react-async-hooks
# or
pnpm add @samyx/react-async-hooks

Requires React 16.8+ (anything with hooks support). React is a peer dependency and is not bundled.

The two hooks

| Hook | Use it when… | | --------------------------------------- | ------------------------------------------------------------------------------------- | | usePromiseCallback | You want to fire an action and await the state change it eventually causes. | | useWatchState | You want to observe a from → to state transition, without triggering anything. |

Both return a stable async function you can await. Both resolve with the final state value and reject with a tagged error.

usePromiseCallback

Wraps an imperative action and hands you back an async function. Awaiting it resolves once a predicate over your watched state says the action's effect has landed.

import { usePromiseCallback } from '@samyx/react-async-hooks';

const executeAction = usePromiseCallback({
  watchedState, // the value whose changes signal completion
  action, // (...args) => void | Promise<void>
  hasReachedTargetState, // (state, args) => boolean  — resolves
  hasFailedReachingTargetState, // (state, args) => boolean  — rejects  (optional)
  timeout, // ms before rejecting with code 'timeout'  (optional)
  concurrency, // 'parallel' | 'queue' | 'block'  (default: 'parallel')
});

const finalState = await executeAction(...args);

Options

| Option | Type | Required | Description | | ------------------------------ | --------------------------------------------- | :------: | ------------------------------------------------------------------------------------------------------------------------ | | watchedState | TState | ✅ | The current value being watched. Changes to it are what resolve/reject pending calls. | | action | (...args: TArgs) => void \| Promise<void> | ✅ | The imperative action. Sync or async. Its return value is not what resolves the promise — hasReachedTargetState is. | | hasReachedTargetState | (state: TState, args: TArgs) => boolean | ✅ | Success predicate. Returning true resolves the promise. | | hasFailedReachingTargetState | (state: TState, args: TArgs) => boolean | | Failure predicate. Returning true rejects the promise with code 'failed'. Omit if there is no failure state. | | timeout | number | | Reject with code 'timeout' after this many milliseconds. | | concurrency | 'parallel' \| 'queue' \| 'block' | | How overlapping calls behave. Default 'parallel'. |

Returns

A stable function (...args: TArgs) => Promise<TState> — its identity does not change across renders, so it is safe to drop into useEffect/useCallback dependency arrays. The promise resolves with the final watched state value (handy for chaining), or rejects with a PromiseCallbackError.

How it resolves

  1. Synchronous short-circuit. Before running the action, both predicates are checked against the current state. If it has already failed → reject immediately. If it has already succeeded → resolve immediately. This is the crucial part: it stops an already-satisfied call from hanging forever waiting for a state change that will never come.
  2. Otherwise the action runs.
  3. Every time watchedState changes, all pending calls are re-checked. First match wins: failure → reject, success → resolve.

Failure is always checked before success — defensively, if state somehow reports both, the failure is what surfaces.

Concurrency

concurrency controls what happens when calls overlap:

  • 'parallel' (default) — every call fires immediately and is tracked independently.
  • 'queue' — calls run one at a time, in order; the rest wait their turn.
  • 'block' — while a call is in flight, new calls reject immediately with code 'blocked'.

Example — wallet connect (no explicit failure state)

The SDK either lands you on the requested wallet or it doesn't. If the user closes the popup there is no "failed" state, so we just rely on timeout.

import { usePromiseCallback } from '@samyx/react-async-hooks';

function useConnectWallet() {
  const connectedWallet = useWalletState(); // current state from the SDK
  const requestConnection = useWalletSdk().requestConnect; // imperative call

  return usePromiseCallback({
    watchedState: connectedWallet,
    action: (walletId: string) => requestConnection(walletId),
    hasReachedTargetState: (current, [walletId]) => current?.id === walletId,
    timeout: 30_000,
    concurrency: 'queue',
  });
}

// at the call site:
const connectWallet = useConnectWallet();

const wallet = await connectWallet('metamask');
const signed = await signMessage(wallet); // safe — connect actually finished

Example — payment (with an explicit failure state)

The payment SDK transitions through statuses and lands on 'succeeded' or 'failed'. We want an immediate rejection on 'failed' — waiting out the 60s timeout would be terrible UX.

import { usePromiseCallback, type PromiseCallbackError } from '@samyx/react-async-hooks';

type PaymentState = {
  status: 'idle' | 'processing' | 'succeeded' | 'failed';
  txId?: string;
  error?: string;
};

function Checkout() {
  const paymentState: PaymentState = usePaymentState();
  const submitPayment = usePaymentSdk().submit;

  const processPayment = usePromiseCallback({
    watchedState: paymentState,
    action: (amount: number, currency: string) => submitPayment({ amount, currency }),
    hasReachedTargetState: (state) => state.status === 'succeeded',
    hasFailedReachingTargetState: (state) => state.status === 'failed',
    timeout: 60_000,
  });

  const pay = async () => {
    try {
      const finalState = await processPayment(99.99, 'USD');
      console.log('Paid, tx:', finalState.txId);
    } catch (err) {
      const e = err as PromiseCallbackError;
      if (e.code === 'failed') showError(paymentState.error ?? 'Payment declined');
      else if (e.code === 'timeout') showError('Payment timed out, please retry');
      else throw err; // the action threw, the component unmounted, etc.
    }
  };

  // ...
}

useWatchState

The observational sibling. There is no action — it just lets you await a from → to state transition.

import { useWatchState } from '@samyx/react-async-hooks';

const watchStateTransition = useWatchState(state);

await watchStateTransition(from, to, { timeout, signal });

Matchers

from and to are each a StateMatcher:

type StateMatcher<TState> = TState | ((state: TState) => boolean);
  • a value — compared with Object.is
  • a predicate(state) => boolean
  • () => true — a wildcard, meaning "don't care about this side"

Per-call options

| Option | Type | Description | | --------- | ------------- | ----------------------------------------------------------------- | | timeout | number | Reject with code 'timeout' after this many milliseconds. | | signal | AbortSignal | Cancel the watch. Rejects with code 'cancelled'. |

Unlike usePromiseCallback, these are passed per call, not at the hook level — every watch gets its own timeout and signal.

Behavior

  • Sticky from. Once from matches at any point — call time or later — it locks in. From then on, any state matching to resolves the promise.
  • Synchronous short-circuit. If from matches now and to matches now, it resolves immediately. (This covers the wildcard-from + already-at-target case.)
  • The subtle case. If from does not match now but to does, it does not resolve — you have not witnessed the transition. State must leave to and re-enter it through from for the watch to fire.
  • Concurrent watches. Call the returned function as many times as you like; each watch is independent, with its own promise, timeout and signal.

Example — strict from → to transition

import { useWatchState, type WatchStateError } from '@samyx/react-async-hooks';

type Status = 'idle' | 'loading' | 'success' | 'error';

function SaveButton() {
  const status: Status = useStatus();
  const watchStatus = useWatchState(status);

  const handleSubmit = async () => {
    startRequest();
    try {
      await watchStatus('loading', 'success', { timeout: 10_000 });
      // we know for sure: status went loading → success
      showToast('Saved!');
    } catch (err) {
      if ((err as WatchStateError).code === 'timeout') showToast('Took too long');
    }
  };

  // ...
}

Example — wildcard from

Use () => true when only the destination matters.

const connection = useConnection(); // { status: 'disconnected' | 'connecting' | 'connected' }
const watchConnection = useWatchState(connection);

await watchConnection(
  () => true, // wildcard from — any starting point
  (conn) => conn.status === 'connected', // predicate to
  { timeout: 30_000 },
);

Example — cancellation via AbortSignal

const controller = new AbortController();

// elsewhere — the user clicks "cancel" or navigates away:
//   controller.abort();

try {
  await watchStatus('idle', 'success', { signal: controller.signal });
} catch (err) {
  const e = err as WatchStateError;
  if (e.code === 'cancelled') {
    /* the watch was aborted */
  } else if (e.code === 'timeout') {
    /* ... */
  }
}

Composing the two hooks

usePromiseCallback fires an action and waits for its effect. useWatchState is purely observational — useful between unrelated steps where you just need to know "and now the state has settled here."

const connectWallet = usePromiseCallback({ ...connectOptions });
const watchTxStatus = useWatchState(txStatus);

const wallet = await connectWallet('metamask'); // act + await
const tx = await sendTx(wallet); // act + await
await watchTxStatus(() => true, 'confirmed'); // observe network confirmation

Error handling

Both hooks reject with a plain Error that carries a tagged code — branch on .code, no instanceof or message parsing needed.

PromiseCallbackError

type PromiseCallbackErrorCode = 'failed' | 'timeout' | 'blocked' | 'unmounted';

| code | Thrown when… | | ------------- | --------------------------------------------------------------------- | | 'failed' | hasFailedReachingTargetState returned true. | | 'timeout' | timeout elapsed before success. | | 'blocked' | concurrency: 'block' and a call was already in flight. | | 'unmounted' | The component unmounted with the call still pending. |

If the action itself throws or rejects, that error propagates as-is. It has no code property, so you can always tell an action failure apart from a hook-generated one.

WatchStateError

type WatchStateErrorCode = 'timeout' | 'cancelled' | 'unmounted';

| code | Thrown when… | | ------------- | ---------------------------------------------------------------------- | | 'timeout' | timeout elapsed before the transition. | | 'cancelled' | The AbortSignal was aborted (either before or during the watch). | | 'unmounted' | The component unmounted with the watch still pending. |

Notes & gotchas

  • Object.is equality (useWatchState only). Value matchers compare with Object.is, which is identity-based for objects and arrays. If your state is an object, pass a predicate rather than a value: (s) => s.status === 'connected'.
  • One watchedState, composed by you. usePromiseCallback watches a single value. When completion depends on several sources, compose them yourself: useMemo(() => ({ a, b, c }), [a, b, c]). This keeps the hook agnostic to whether the sources are state, refs, memos or signals.
  • Stable callbacks. executeAction and watchStateTransition keep the same identity across renders, so they will not poison downstream dependency arrays. Internally they read the latest options and state through refs.
  • timeout / concurrency are hook-level for usePromiseCallback (set once in the options object). For useWatchState, timeout / signal are per call.
  • Unmount safety. Pending promises reject with code 'unmounted' when the component unmounts, so awaiters never hang.
  • Server rendering. Both hooks are render-safe on the server (they only set up refs and effects). The returned async functions are meant to be invoked on the client, from effects or event handlers.

API reference

import {
  usePromiseCallback,
  useWatchState,
  type UsePromiseCallbackOptions,
  type PromiseCallbackError,
  type PromiseCallbackErrorCode,
  type StateMatcher,
  type WatchStateTransitionOptions,
  type WatchStateError,
  type WatchStateErrorCode,
} from '@samyx/react-async-hooks';

| Export | Kind | Description | | ----------------------------- | -------- | -------------------------------------------------------- | | usePromiseCallback | function | Fire-and-await hook. | | useWatchState | function | State-transition watcher hook. | | UsePromiseCallbackOptions | type | Options object for usePromiseCallback. | | PromiseCallbackError | type | Error shape thrown by usePromiseCallback. | | PromiseCallbackErrorCode | type | Union of usePromiseCallback error codes. | | StateMatcher | type | A value or predicate accepted by useWatchState. | | WatchStateTransitionOptions | type | Per-call options (timeout, signal) for a watch. | | WatchStateError | type | Error shape thrown by useWatchState. | | WatchStateErrorCode | type | Union of useWatchState error codes. |

Development

This package lives in the @samyx monorepo. From packages/react-async-hooks:

| Script | Description | | ----------------------- | ---------------------------------------- | | bun run build | Bundle to dist/ (ESM + CJS + types). | | bun run test | Run the test suite once. | | bun run test:watch | Run the test suite in watch mode. | | bun run test:coverage | Run tests with a coverage report. | | bun run typecheck | Type-check with tsc --noEmit. |

License

MIT © samimishal