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).You'll hold status, value, and error in state and expose a memoized execute that drives them through the async lifecycle — optionally kicked off once on mount.
Every promise a component runs goes through the same arc: it hasn't started (idle), it's running (pending), and it either succeeded (success, with a value) or failed (error, with a reason). Components re-implement that arc constantly, usually with three useStates and a lot of copy-paste. useAsync factors it out into one hook that works for any promise-returning function, and separates "what to run" (the function) from "when to run it" (execute, or the immediate flag on mount).
Two decisions: the state machine and the trigger. The state machine is idle → pending → success | error, and execute is what advances it — reset to pending, then land on success or error when the promise settles. The trigger is who calls execute: the immediate flag calls it once from an effect on mount; otherwise an event handler calls it. Keeping execute stable (memoized) lets it be both an effect dependency and a click handler without churning.
The naive version tracks a single boolean and forgets to reset between runs:
function useAsyncNaive(asyncFunction) {
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
const execute = () => { // new identity every render
setLoading(true);
return asyncFunction()
.then((v) => setValue(v)) // stale error left in place
.catch((e) => setError(e))
.finally(() => setLoading(false));
};
return { execute, loading, value, error };
}
Three problems. A boolean loading can't express idle vs success vs error — you can't tell "never ran" from "ran and succeeded". Re-running doesn't clear the previous error or value, so a retry that succeeds still shows the old error. And execute is recreated every render, so it can't safely go in an effect's dependency array (it'd re-run forever) or be compared by memoized children.
const { useState, useCallback, useEffect } = require('react');
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
const execute = useCallback(
(...args) => {
setStatus('pending');
setValue(null); // clear stale results up front
setError(null);
return asyncFunction(...args)
.then((response) => {
setValue(response);
setStatus('success');
return response; // so callers can await the value
})
.catch((err) => {
setError(err);
setStatus('error');
});
},
[asyncFunction],
);
useEffect(() => {
if (immediate) execute();
}, [execute, immediate]);
return { execute, status, value, error };
}
module.exports = { useAsync };
A four-value status string replaces the boolean, so every stage is distinguishable. execute resets pending/value/error before awaiting, so a retry starts clean, then settles the state on resolve or reject and returns the response for callers who await execute(). It's wrapped in useCallback([asyncFunction]), giving it a stable identity as long as the passed function is stable — which is exactly what makes the mount effect safe: useEffect(() => { if (immediate) execute() }, [execute, immediate]) runs once, not every render. Separating execute from the effect is what lets the same hook serve both "load on mount" and "run on click".
Use useAsync(saveDraft, false) (manual), then click Save, which rejects, then click again and it succeeds:
status is idle; immediate is false, so nothing runs. The button reads "Save".execute(draft): status → 'pending', value/error cleared. Button disables to "Saving…". saveDraft rejects → catch sets error, status → 'error'. Button shows the error.execute(draft): status → 'pending' again, and crucially error is cleared now, so the UI doesn't show a stale error while retrying. saveDraft resolves → value set, status → 'success'.The reset-on-execute is what makes the retry feel correct; without it, click 2 would flash the old error until it resolved.
'idle' | 'pending' | 'success' | 'error' string.pending.execute — recreating it every render makes the mount effect loop and breaks memoized consumers. useCallback on asyncFunction.asyncFunction — passing a fresh function each render changes execute, re-triggering the immediate effect. Memoize it at the call site (or wrap it in useCallback).AbortController) lets a newer execute invalidate an older one's result, the same race fix useFetch needs.useAsyncRetry — layering a retry() that re-runs the last call on top of this hook is a small, common extension.useStates into one useReducer makes the transitions atomic and the state machine explicit.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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).You'll hold status, value, and error in state and expose a memoized execute that drives them through the async lifecycle — optionally kicked off once on mount.
Every promise a component runs goes through the same arc: it hasn't started (idle), it's running (pending), and it either succeeded (success, with a value) or failed (error, with a reason). Components re-implement that arc constantly, usually with three useStates and a lot of copy-paste. useAsync factors it out into one hook that works for any promise-returning function, and separates "what to run" (the function) from "when to run it" (execute, or the immediate flag on mount).
Two decisions: the state machine and the trigger. The state machine is idle → pending → success | error, and execute is what advances it — reset to pending, then land on success or error when the promise settles. The trigger is who calls execute: the immediate flag calls it once from an effect on mount; otherwise an event handler calls it. Keeping execute stable (memoized) lets it be both an effect dependency and a click handler without churning.
The naive version tracks a single boolean and forgets to reset between runs:
function useAsyncNaive(asyncFunction) {
const [loading, setLoading] = useState(false);
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
const execute = () => { // new identity every render
setLoading(true);
return asyncFunction()
.then((v) => setValue(v)) // stale error left in place
.catch((e) => setError(e))
.finally(() => setLoading(false));
};
return { execute, loading, value, error };
}
Three problems. A boolean loading can't express idle vs success vs error — you can't tell "never ran" from "ran and succeeded". Re-running doesn't clear the previous error or value, so a retry that succeeds still shows the old error. And execute is recreated every render, so it can't safely go in an effect's dependency array (it'd re-run forever) or be compared by memoized children.
const { useState, useCallback, useEffect } = require('react');
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [value, setValue] = useState(null);
const [error, setError] = useState(null);
const execute = useCallback(
(...args) => {
setStatus('pending');
setValue(null); // clear stale results up front
setError(null);
return asyncFunction(...args)
.then((response) => {
setValue(response);
setStatus('success');
return response; // so callers can await the value
})
.catch((err) => {
setError(err);
setStatus('error');
});
},
[asyncFunction],
);
useEffect(() => {
if (immediate) execute();
}, [execute, immediate]);
return { execute, status, value, error };
}
module.exports = { useAsync };
A four-value status string replaces the boolean, so every stage is distinguishable. execute resets pending/value/error before awaiting, so a retry starts clean, then settles the state on resolve or reject and returns the response for callers who await execute(). It's wrapped in useCallback([asyncFunction]), giving it a stable identity as long as the passed function is stable — which is exactly what makes the mount effect safe: useEffect(() => { if (immediate) execute() }, [execute, immediate]) runs once, not every render. Separating execute from the effect is what lets the same hook serve both "load on mount" and "run on click".
Use useAsync(saveDraft, false) (manual), then click Save, which rejects, then click again and it succeeds:
status is idle; immediate is false, so nothing runs. The button reads "Save".execute(draft): status → 'pending', value/error cleared. Button disables to "Saving…". saveDraft rejects → catch sets error, status → 'error'. Button shows the error.execute(draft): status → 'pending' again, and crucially error is cleared now, so the UI doesn't show a stale error while retrying. saveDraft resolves → value set, status → 'success'.The reset-on-execute is what makes the retry feel correct; without it, click 2 would flash the old error until it resolved.
'idle' | 'pending' | 'success' | 'error' string.pending.execute — recreating it every render makes the mount effect loop and breaks memoized consumers. useCallback on asyncFunction.asyncFunction — passing a fresh function each render changes execute, re-triggering the immediate effect. Memoize it at the call site (or wrap it in useCallback).AbortController) lets a newer execute invalidate an older one's result, the same race fix useFetch needs.useAsyncRetry — layering a retry() that re-runs the last call on top of this hook is a small, common extension.useStates into one useReducer makes the transitions atomic and the state machine explicit.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.