Build a custom hook that runs an asynchronous request and reports its progress to the component. Fetching data is never a single value — at any moment a request is either still in flight, finished with a result, or finished with a failure. useQuery(queryFn) takes a function that returns a Promise, runs it, and hands back { data, error, isLoading } so the component can render a spinner, the result, or an error message without wiring up the bookkeeping itself.
function useQuery<T>(queryFn: () => Promise<T>): {
data: T | undefined;
error: Error | undefined;
isLoading: boolean;
};
The hook runs queryFn on mount and again whenever the queryFn reference changes. While a request is in flight, isLoading is true and both data and error are undefined.
function Profile({ userId }) {
const fetchUser = useCallback(
() => fetch(`/api/users/${userId}`).then((r) => r.json()),
[userId],
);
const { data, error, isLoading } = useQuery(fetchUser);
if (isLoading) return <Spinner />;
if (error) return <p>Could not load: {error.message}</p>;
return <h1>{data.name}</h1>;
}
// Lifecycle for a query that resolves:
// mount: { data: undefined, error: undefined, isLoading: true }
// resolve: { data: <result>, error: undefined, isLoading: false }
// Lifecycle for a query that rejects:
// mount: { data: undefined, error: undefined, isLoading: true }
// reject: { data: undefined, error: <Error>, isLoading: false }
isLoading is true and there is no data or error yet.data and leaves error undefined; a rejected query sets error and leaves data undefined. Both flip isLoading to false.0, '', null, or false, that value is the data and loading is done. "Resolved with a falsy value" is not the same as "still loading."queryFn. When the queryFn reference changes, start a fresh request. Callers are expected to memoize queryFn (e.g. with useCallback) so it only changes when the underlying inputs change.ignore flag so a late result is dropped instead of clobbering the current one or warning about an update after unmount.You'll wrap an asynchronous request in state, kicking it off in an effect and recording whether it is still running, what it returned, or how it failed.
A component asks for data — a user profile, a list of orders — and the answer doesn't arrive instantly. In that gap the component needs to show something: a spinner while it waits, the data once it lands, an error message if the request blows up. Those are three distinct states, and a request moves from the first into exactly one of the other two. useQuery owns that little state machine. You give it a function that returns a Promise; it runs the function, watches the Promise, and reports back { data, error, isLoading } so your component can just describe what each state looks like.
A Promise has exactly two ways to finish: it resolves with a value or it rejects with a reason. Map those onto state. The request starts in a loading state. When the Promise resolves, you move to a data state — store the value, stop loading. When it rejects, you move to an error state — store the reason, stop loading. The hook holds all three pieces in useState and starts the request inside a useEffect, because kicking off a network call is a side effect that should run after render, not during it.
The obvious version starts the request in an effect and pipes the result straight into state:
const { useState, useEffect } = require('react');
function useQuery(queryFn) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
queryFn().then((result) => {
setData(result);
setIsLoading(false);
});
}, [queryFn]);
return { data, error, isLoading };
}
This handles the happy path and nothing else. There is no .catch, so a rejected Promise is never observed: error stays undefined, isLoading stays true, and the component is wedged on the spinner forever while a real failure goes unreported (often surfacing as an unhandled-rejection warning instead). It also never accounts for a request being superseded or the component unmounting before the Promise settles — a late result will try to set state on a component that's gone.
const { useState, useEffect } = require('react');
function useQuery(queryFn) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// `ignore` belongs to THIS run of the effect. Its cleanup flips it to
// true, so a promise from a superseded or unmounted run sees its own
// closed-over flag as true and writes nothing.
let ignore = false;
// Reset to a clean loading state every time the query (re)starts.
setIsLoading(true);
setData(undefined);
setError(undefined);
queryFn()
.then((result) => {
if (ignore) return; // a newer run (or unmount) has taken over
setData(result);
setIsLoading(false);
})
.catch((err) => {
if (ignore) return;
setError(err); // the missing half of the naive version
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [queryFn]); // re-run whenever the caller hands us a new queryFn
return { data, error, isLoading };
}
module.exports = { useQuery };
Two things change from the naive version. First, the .catch closes the loop: a rejected Promise now lands in the error state instead of vanishing. Second, the ignore flag — read at the top of both handlers and set in the effect's cleanup — makes every settled Promise check whether its run is still the current one before touching state. Because each effect run gets its own ignore variable in its own closure, an old promise can never write over a newer one's results, and a promise that settles after unmount quietly does nothing.
Say a component renders with queryFn A, then re-renders with a new queryFn B before A finishes:
{ data: undefined, error: undefined, isLoading: true }. The effect runs: ignore is false, it resets the loading state and calls A(), which returns a pending Promise.queryFn changed, React runs the effect's cleanup first — setting run 1's ignore = true — then runs the effect again with a fresh ignore = false and calls B()..then checks ignore, which (in A's closure) is now true, so it returns immediately and sets no state. The stale result is dropped..then sees ignore still false, so it sets data to B's result and flips isLoading to false.The component ends on B's data, never flickering to A's. The same cleanup protects against unmount: if the component is gone when A resolves, ignore is true and nothing is written, so React never warns about a state update on an unmounted component.
.catch at all. With only .then, a rejected Promise never sets error and isLoading is stuck true — the user stares at a spinner while the request has actually failed. Fix: add a .catch that sets error and clears isLoading.ignore flag set in the effect cleanup, checked before every setter.queryFn changes mid-flight and you don't guard, whichever Promise resolves last wins — which might be the old one, showing stale data. Fix: the same per-run ignore flag, since cleanup runs before the next effect starts.if (data) setIsLoading(false) breaks when the query resolves with 0, '', or null. Fix: flip isLoading in the .then itself, regardless of the value's truthiness.refetch function that re-runs queryFn on demand, so a "retry" button or a polling interval can restart the request without changing the function reference.AbortController in the effect, pass its signal to fetch, and call abort() in the cleanup. That cancels the in-flight request instead of merely ignoring its result.{ data, error, isLoading } shape here is the same foundation they build on.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a custom hook that runs an asynchronous request and reports its progress to the component. Fetching data is never a single value — at any moment a request is either still in flight, finished with a result, or finished with a failure. useQuery(queryFn) takes a function that returns a Promise, runs it, and hands back { data, error, isLoading } so the component can render a spinner, the result, or an error message without wiring up the bookkeeping itself.
function useQuery<T>(queryFn: () => Promise<T>): {
data: T | undefined;
error: Error | undefined;
isLoading: boolean;
};
The hook runs queryFn on mount and again whenever the queryFn reference changes. While a request is in flight, isLoading is true and both data and error are undefined.
function Profile({ userId }) {
const fetchUser = useCallback(
() => fetch(`/api/users/${userId}`).then((r) => r.json()),
[userId],
);
const { data, error, isLoading } = useQuery(fetchUser);
if (isLoading) return <Spinner />;
if (error) return <p>Could not load: {error.message}</p>;
return <h1>{data.name}</h1>;
}
// Lifecycle for a query that resolves:
// mount: { data: undefined, error: undefined, isLoading: true }
// resolve: { data: <result>, error: undefined, isLoading: false }
// Lifecycle for a query that rejects:
// mount: { data: undefined, error: undefined, isLoading: true }
// reject: { data: undefined, error: <Error>, isLoading: false }
isLoading is true and there is no data or error yet.data and leaves error undefined; a rejected query sets error and leaves data undefined. Both flip isLoading to false.0, '', null, or false, that value is the data and loading is done. "Resolved with a falsy value" is not the same as "still loading."queryFn. When the queryFn reference changes, start a fresh request. Callers are expected to memoize queryFn (e.g. with useCallback) so it only changes when the underlying inputs change.ignore flag so a late result is dropped instead of clobbering the current one or warning about an update after unmount.You'll wrap an asynchronous request in state, kicking it off in an effect and recording whether it is still running, what it returned, or how it failed.
A component asks for data — a user profile, a list of orders — and the answer doesn't arrive instantly. In that gap the component needs to show something: a spinner while it waits, the data once it lands, an error message if the request blows up. Those are three distinct states, and a request moves from the first into exactly one of the other two. useQuery owns that little state machine. You give it a function that returns a Promise; it runs the function, watches the Promise, and reports back { data, error, isLoading } so your component can just describe what each state looks like.
A Promise has exactly two ways to finish: it resolves with a value or it rejects with a reason. Map those onto state. The request starts in a loading state. When the Promise resolves, you move to a data state — store the value, stop loading. When it rejects, you move to an error state — store the reason, stop loading. The hook holds all three pieces in useState and starts the request inside a useEffect, because kicking off a network call is a side effect that should run after render, not during it.
The obvious version starts the request in an effect and pipes the result straight into state:
const { useState, useEffect } = require('react');
function useQuery(queryFn) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
queryFn().then((result) => {
setData(result);
setIsLoading(false);
});
}, [queryFn]);
return { data, error, isLoading };
}
This handles the happy path and nothing else. There is no .catch, so a rejected Promise is never observed: error stays undefined, isLoading stays true, and the component is wedged on the spinner forever while a real failure goes unreported (often surfacing as an unhandled-rejection warning instead). It also never accounts for a request being superseded or the component unmounting before the Promise settles — a late result will try to set state on a component that's gone.
const { useState, useEffect } = require('react');
function useQuery(queryFn) {
const [data, setData] = useState(undefined);
const [error, setError] = useState(undefined);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// `ignore` belongs to THIS run of the effect. Its cleanup flips it to
// true, so a promise from a superseded or unmounted run sees its own
// closed-over flag as true and writes nothing.
let ignore = false;
// Reset to a clean loading state every time the query (re)starts.
setIsLoading(true);
setData(undefined);
setError(undefined);
queryFn()
.then((result) => {
if (ignore) return; // a newer run (or unmount) has taken over
setData(result);
setIsLoading(false);
})
.catch((err) => {
if (ignore) return;
setError(err); // the missing half of the naive version
setIsLoading(false);
});
return () => {
ignore = true;
};
}, [queryFn]); // re-run whenever the caller hands us a new queryFn
return { data, error, isLoading };
}
module.exports = { useQuery };
Two things change from the naive version. First, the .catch closes the loop: a rejected Promise now lands in the error state instead of vanishing. Second, the ignore flag — read at the top of both handlers and set in the effect's cleanup — makes every settled Promise check whether its run is still the current one before touching state. Because each effect run gets its own ignore variable in its own closure, an old promise can never write over a newer one's results, and a promise that settles after unmount quietly does nothing.
Say a component renders with queryFn A, then re-renders with a new queryFn B before A finishes:
{ data: undefined, error: undefined, isLoading: true }. The effect runs: ignore is false, it resets the loading state and calls A(), which returns a pending Promise.queryFn changed, React runs the effect's cleanup first — setting run 1's ignore = true — then runs the effect again with a fresh ignore = false and calls B()..then checks ignore, which (in A's closure) is now true, so it returns immediately and sets no state. The stale result is dropped..then sees ignore still false, so it sets data to B's result and flips isLoading to false.The component ends on B's data, never flickering to A's. The same cleanup protects against unmount: if the component is gone when A resolves, ignore is true and nothing is written, so React never warns about a state update on an unmounted component.
.catch at all. With only .then, a rejected Promise never sets error and isLoading is stuck true — the user stares at a spinner while the request has actually failed. Fix: add a .catch that sets error and clears isLoading.ignore flag set in the effect cleanup, checked before every setter.queryFn changes mid-flight and you don't guard, whichever Promise resolves last wins — which might be the old one, showing stale data. Fix: the same per-run ignore flag, since cleanup runs before the next effect starts.if (data) setIsLoading(false) breaks when the query resolves with 0, '', or null. Fix: flip isLoading in the .then itself, regardless of the value's truthiness.refetch function that re-runs queryFn on demand, so a "retry" button or a polling interval can restart the request without changing the function reference.AbortController in the effect, pass its signal to fetch, and call abort() in the cleanup. That cancels the in-flight request instead of merely ignoring its result.{ data, error, isLoading } shape here is the same foundation they build on.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.