The slow response that overwrites the correct screen
A search for 'rea' starts first, then a search for 'react' starts second. If the first request finishes last and updates state, the interface can display results for the wrong query. This is a race condition, not a React rendering bug.
Unmounting creates another version of the problem: a request may complete after the screen that started it no longer exists.
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(response => response.json())
.then(setResults);
}, [query]);Cancel the work you no longer need
AbortController gives fetch a lifecycle. Create it inside the effect, pass its signal, and abort in cleanup. A cancelled request is expected control flow, so do not turn its AbortError into a scary user-facing error.
useEffect(() => {
const controller = new AbortController();
async function load() {
const response = await fetch(url, { signal: controller.signal });
setData(await response.json());
}
load().catch(error => {
if (error.name !== "AbortError") setError(error);
});
return () => controller.abort();
}, [url]);Model the states people can actually see
A boolean loading flag collapses too much. A useful request model distinguishes initial loading, refreshing existing data, empty success, failure with no data, and failure while stale data is still useful. Those states produce better decisions for both design and code.
Debounce input to avoid starting needless requests, but do not confuse debouncing with correctness. Cancellation or a request version check still protects against out-of-order results.
- Initial load: show a skeleton or purposeful pending state.
- Refresh: retain previous results and show non-blocking progress.
- Empty: say what was searched and offer a next action.
- Error: preserve usable old data where appropriate and allow retry.
Know when not to write this hook
A custom hook is fine for a narrow endpoint. Once multiple screens need cache invalidation, request deduplication, background refresh, and mutations, use the query library already chosen by your application. The durable lesson is not the hook; it is assigning ownership of server data to one predictable cache.
Key Takeaways
- Every request needs a lifecycle tied to the UI that initiated it.
- Debouncing reduces traffic; cancellation prevents stale updates.
- Treat server data as a cache-coherency problem, not merely component state.
Ready to explain this under interview pressure?
Learn the mental models, then practise applying them to realistic frontend problems.
Join Cohort 3 Waitlist