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.