Almost every screen loads data, and the boilerplate is always the same: three pieces of state (data, error, loading), an effect to kick off the request, and — the part people forget — a guard so that when the URL changes fast, a slow earlier response can't clobber a newer one. useFetch packages all of that: pass a URL, get back { data, error, loading } that refetches when the URL changes and ignores stale replies.
Implement useFetch(url, options). It fetches JSON and returns { data, error, loading }. It refetches whenever url changes, treats a non-2xx response as an error, and discards any response that arrives after the URL has moved on or the component has unmounted.
function useFetch(url, options) {
// returns { data, error, loading }
}
function User({ id }) {
const { data, error, loading } = useFetch(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={data} />;
}
// Typing fast in a search box changes the url rapidly; only the
// response for the CURRENT url is allowed to set state.
const { data } = useFetch(`/search?q=${query}`);
loading true while in flight; on success set data and clear error; on failure set error and clear data.fetch only rejects on network failure, so check response.ok and throw for HTTP errors like 404/500.url; a falsy url should do nothing.You'll drive one { data, error, loading } state from an effect keyed on url, checking response.ok for HTTP errors and using an active flag flipped in cleanup to discard stale responses.
A fetch hook has to model three states and one race. The three states are the request lifecycle: loading while in flight, success with data, failure with an error. The race is subtler: if url changes while a request is still pending — a user typing in a search box, tabs switching fast — two requests are in flight, and network timing doesn't guarantee they resolve in order. If the first (now-stale) request resolves last, its data overwrites the newer request's data, and the UI shows the wrong thing. The fix is to mark each request "active" and ignore any that's no longer the current one.
Think of each render of the effect as dispatching a runner to fetch data. Before dispatching a new runner (because url changed), you tell the old one: "don't bother reporting back." That instruction is the cleanup function flipping a per-effect active flag to false. When a runner returns, it checks its own flag; if it's been told to stand down, it drops the result silently. Only the current runner's result reaches state.
The naive version wires up state and an effect but skips the race guard and the HTTP-error check:
function useFetchNaive(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json()) // ignores res.ok
.then((json) => {
setData(json); // no stale-response guard
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [url]);
return { data, error, loading };
}
Two holes. It calls res.json() without checking res.ok, so a 404 or 500 — which fetch treats as a successful promise — is parsed as if it were data. And with no cleanup, a request for an old url that resolves after a newer one will call setData with stale data, overwriting the fresh result. Fast-changing URLs make this a visible bug.
const { useState, useEffect } = require('react');
function useFetch(url, options) {
const [state, setState] = useState({ data: null, error: null, loading: false });
useEffect(() => {
if (!url) return;
let active = true; // is THIS request still the current one?
setState({ data: null, error: null, loading: true });
fetch(url, options)
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => {
if (active) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (active) setState({ data: null, error, loading: false });
});
return () => {
active = false; // a newer url (or unmount) retires this request
};
}, [url]);
return state;
}
module.exports = { useFetch };
State lives in a single object so each transition sets all three fields consistently — no half-updated renders. The effect bails on a falsy url, otherwise sets loading: true and starts the request. The if (!res.ok) throw turns HTTP errors into rejections that the catch handles. The key is let active = true plus the cleanup active = false: each effect run gets its own active closure. When url changes, React runs the previous effect's cleanup — flipping that request's active to false — before starting the new one. So both .then and .catch gate their setState on active, and a stale request that resolves late finds its flag already false and does nothing.
Type "a" then "ab" quickly, so url goes /search?q=a → /search?q=ab, and the first request happens to resolve last:
q=a — effect runs: activeA = true, setState(loading), fetch('/search?q=a') starts.q=ab — React runs the q=a effect's cleanup: activeA = false. Then the new effect runs: activeB = true, setState(loading), fetch('/search?q=ab') starts.q=ab resolves first — its .then checks activeB (true) → setState({ data: resultsForAB, loading: false }). UI shows "ab" results.q=a resolves later — its .then checks activeA (false) → does nothing. The stale "a" results are dropped; the UI keeps showing "ab".Without the flag, that last step would overwrite "ab" with "a" — the classic search-box flicker bug.
res.ok — fetch resolves on 404/500; without the check you parse an error page as data. Throw on !res.ok.active flag flipped in cleanup.options in the dependency array — a fresh options object each render would refetch every render; here we key only on url. If options are dynamic, memoize them.useStates can render intermediate combinations (e.g. data set but loading still true); one object keeps transitions atomic.AbortController — instead of only ignoring a stale response, calling controller.abort() in cleanup cancels the in-flight request outright, freeing the connection.refetch() function lets callers re-run the current request on demand, e.g. after a mutation.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Almost every screen loads data, and the boilerplate is always the same: three pieces of state (data, error, loading), an effect to kick off the request, and — the part people forget — a guard so that when the URL changes fast, a slow earlier response can't clobber a newer one. useFetch packages all of that: pass a URL, get back { data, error, loading } that refetches when the URL changes and ignores stale replies.
Implement useFetch(url, options). It fetches JSON and returns { data, error, loading }. It refetches whenever url changes, treats a non-2xx response as an error, and discards any response that arrives after the URL has moved on or the component has unmounted.
function useFetch(url, options) {
// returns { data, error, loading }
}
function User({ id }) {
const { data, error, loading } = useFetch(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={data} />;
}
// Typing fast in a search box changes the url rapidly; only the
// response for the CURRENT url is allowed to set state.
const { data } = useFetch(`/search?q=${query}`);
loading true while in flight; on success set data and clear error; on failure set error and clear data.fetch only rejects on network failure, so check response.ok and throw for HTTP errors like 404/500.url; a falsy url should do nothing.You'll drive one { data, error, loading } state from an effect keyed on url, checking response.ok for HTTP errors and using an active flag flipped in cleanup to discard stale responses.
A fetch hook has to model three states and one race. The three states are the request lifecycle: loading while in flight, success with data, failure with an error. The race is subtler: if url changes while a request is still pending — a user typing in a search box, tabs switching fast — two requests are in flight, and network timing doesn't guarantee they resolve in order. If the first (now-stale) request resolves last, its data overwrites the newer request's data, and the UI shows the wrong thing. The fix is to mark each request "active" and ignore any that's no longer the current one.
Think of each render of the effect as dispatching a runner to fetch data. Before dispatching a new runner (because url changed), you tell the old one: "don't bother reporting back." That instruction is the cleanup function flipping a per-effect active flag to false. When a runner returns, it checks its own flag; if it's been told to stand down, it drops the result silently. Only the current runner's result reaches state.
The naive version wires up state and an effect but skips the race guard and the HTTP-error check:
function useFetchNaive(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json()) // ignores res.ok
.then((json) => {
setData(json); // no stale-response guard
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [url]);
return { data, error, loading };
}
Two holes. It calls res.json() without checking res.ok, so a 404 or 500 — which fetch treats as a successful promise — is parsed as if it were data. And with no cleanup, a request for an old url that resolves after a newer one will call setData with stale data, overwriting the fresh result. Fast-changing URLs make this a visible bug.
const { useState, useEffect } = require('react');
function useFetch(url, options) {
const [state, setState] = useState({ data: null, error: null, loading: false });
useEffect(() => {
if (!url) return;
let active = true; // is THIS request still the current one?
setState({ data: null, error: null, loading: true });
fetch(url, options)
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => {
if (active) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (active) setState({ data: null, error, loading: false });
});
return () => {
active = false; // a newer url (or unmount) retires this request
};
}, [url]);
return state;
}
module.exports = { useFetch };
State lives in a single object so each transition sets all three fields consistently — no half-updated renders. The effect bails on a falsy url, otherwise sets loading: true and starts the request. The if (!res.ok) throw turns HTTP errors into rejections that the catch handles. The key is let active = true plus the cleanup active = false: each effect run gets its own active closure. When url changes, React runs the previous effect's cleanup — flipping that request's active to false — before starting the new one. So both .then and .catch gate their setState on active, and a stale request that resolves late finds its flag already false and does nothing.
Type "a" then "ab" quickly, so url goes /search?q=a → /search?q=ab, and the first request happens to resolve last:
q=a — effect runs: activeA = true, setState(loading), fetch('/search?q=a') starts.q=ab — React runs the q=a effect's cleanup: activeA = false. Then the new effect runs: activeB = true, setState(loading), fetch('/search?q=ab') starts.q=ab resolves first — its .then checks activeB (true) → setState({ data: resultsForAB, loading: false }). UI shows "ab" results.q=a resolves later — its .then checks activeA (false) → does nothing. The stale "a" results are dropped; the UI keeps showing "ab".Without the flag, that last step would overwrite "ab" with "a" — the classic search-box flicker bug.
res.ok — fetch resolves on 404/500; without the check you parse an error page as data. Throw on !res.ok.active flag flipped in cleanup.options in the dependency array — a fresh options object each render would refetch every render; here we key only on url. If options are dynamic, memoize them.useStates can render intermediate combinations (e.g. data set but loading still true); one object keeps transitions atomic.AbortController — instead of only ignoring a stale response, calling controller.abort() in cleanup cancels the in-flight request outright, freeing the connection.refetch() function lets callers re-run the current request on demand, e.g. after a mutation.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.