Implement a useQuery hook that manages a promise that can be used to fetch data.
export default function Component({ param }) {const request = useQuery(async () => {const response = await getDataFromServer(param);return response.data;}, [param]);if (request.status === 'loading') {return <p>Loading...</p>;}if (request.status === 'error') {const message =request.error instanceof Error? request.error.message: String(request.error);return <p>Error: {message}</p>;}return <p>Data: {request.data}</p>;}
fn: () => Promise: A function that returns a promisedeps: DependencyList: An array of dependencies, similar to the second argument of useEffect. Unlike useEffect, this defaults to []The hook returns an object that has different properties depending on the state of the promise.
status: 'loading': The promise is still pendingstatus: 'error': The promise was rejectederror: unknown: The value that caused the promise to be rejectedstatus: 'success': The promise was resolveddata: The data returned when the promise from fn resolves{ status: 'loading' } when it starts a new request.deps should start a new request and ignore stale results from earlier requests.useQuery(fn, deps) tracks one request generation at a time. The hook returns the public state for the current generation: loading while fn() is pending, success with data when it fulfills, or error with the rejected value when it rejects.
The main correctness concern is stale async work. A promise callback can run after the dependencies have changed or after the component has unmounted, so older generations must not be allowed to overwrite the current state.
The code uses two React primitives:
useState stores the result object returned to consumers.useEffect starts a new request generation whenever deps change and cleans up the previous generation.Each effect run follows this lifecycle:
ignore flag.{ status: 'loading' }.fn().{ status: 'success', data } if the promise fulfills and this generation is still current.{ status: 'error', error } if the promise rejects and this generation is still current.The guard is local to the effect run:
let ignore = false;fn().then((data) => {if (!ignore) {setState({ status: 'success', data });}});return () => {ignore = true;};
No ref is needed for this guard because each request generation already has its own closure. When deps change, React runs the cleanup for the old closure before the next effect writes state for the new one.
| Time | Generation | Event | State write? |
|---|---|---|---|
t0 | request A | effect starts | loading |
t1 | request A | deps change cleanup | A marked ignored |
t1 | request B | new effect starts | loading |
t2 | request A settles late | skipped | no |
t3 | request B settles | current | success or error |
import { useEffect, useState } from 'react';/*** @template T* @param {() => Promise<T>} fn* @param {import("react").DependencyList} deps*/export default function useQuery(fn, deps = []) {const [state, setState] = useState({status: 'loading',});useEffect(() => {// Ignore late promise resolutions after deps change or the component unmounts.let ignore = false;setState({ status: 'loading' });fn().then((data) => {if (ignore) {return;}setState({ status: 'success', data });}).catch((error) => {if (ignore) {return;}setState({ status: 'error', error });});return () => {ignore = true;};}, deps);return state;}
Letting stale promises win: Without the cleanup guard, an older request can resolve or reject after a newer request has started and overwrite the newer result. This is why both the fulfillment and rejection handlers check whether their generation has been ignored.
Adding fn to the effect dependencies: The hook API says deps controls when the request reruns. Including fn in the effect dependency list would make an inline callback rerun on every render because its identity changes each time.
Keeping old fields in the next state: Use a single result object for each state instead of separate status, data, and error state variables. Resetting to { status: 'loading' } clears old data or errors and keeps the returned object aligned with the active status.
deps defaults to [], so omitting it runs the query once after mount.status before reading data or error.unknown in TypeScript.console.log() statements will appear here.