useFetch is great when the thing you load is a URL, but plenty of async work isn't a GET — submitting a form, calling an SDK, running a computation in a worker. useAsync generalizes the lifecycle: hand it any function that returns a promise and it tracks the four states every async operation moves through — idle, pending, success, error — and hands you an execute trigger so you decide when it runs.
Implement useAsync(asyncFunction, immediate = true). It returns { execute, status, value, error }. execute(...args) runs the function, flipping status to pending, then to success with the resolved value or error with the rejection. When immediate is true it runs once on mount; otherwise it waits for a manual execute.
function useAsync(asyncFunction, immediate = true) {
// returns { execute, status, value, error }
// status: 'idle' | 'pending' | 'success' | 'error'
}
// Manual trigger — run on button click, not on mount.
const { execute, status } = useAsync(submitForm, false);
<button disabled={status === 'pending'} onClick={() => execute(formData)}>
{status === 'pending' ? 'Saving…' : 'Save'}
</button>
// Immediate — load on mount.
const { status, value, error } = useAsync(() => loadDashboard(userId));
idle; execute sets pending (and clears prior value/error); resolve → success + value; reject → error + error.execute forwards args — execute(a, b) calls asyncFunction(a, b), and returns the promise so callers can await it.immediate gates the mount run — true runs on mount via an effect; false leaves it idle until called.execute — memoize it on asyncFunction so it's safe in effect deps and event handlers. Assume asyncFunction is stable (memoized by the caller).