@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.
Maintainers
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
- Installation
- The two hooks
usePromiseCallbackuseWatchState- Composing the two hooks
- Error handling
- Notes & gotchas
- API reference
- Development
- License
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-hooksRequires 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
- 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.
- Otherwise the action runs.
- Every time
watchedStatechanges, 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 finishedExample — 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. Oncefrommatches at any point — call time or later — it locks in. From then on, any state matchingtoresolves the promise. - Synchronous short-circuit. If
frommatches now andtomatches now, it resolves immediately. (This covers the wildcard-from+ already-at-target case.) - The subtle case. If
fromdoes not match now buttodoes, it does not resolve — you have not witnessed the transition. State must leavetoand re-enter it throughfromfor 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 confirmationError 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
actionitself throws or rejects, that error propagates as-is. It has nocodeproperty, 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.isequality (useWatchStateonly). Value matchers compare withObject.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.usePromiseCallbackwatches 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.
executeActionandwatchStateTransitionkeep the same identity across renders, so they will not poison downstream dependency arrays. Internally they read the latest options and state through refs. timeout/concurrencyare hook-level forusePromiseCallback(set once in the options object). ForuseWatchState,timeout/signalare 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
