Home / Interview preparation
React Interview Exercise: Prevent Stale API Responses
A search box can show the wrong results even when every API request succeeds. This exercise asks you to explain the race, protect the state update and test the behaviour under reversed response order. It assumes a client-rendered React component and an endpoint returning a JSON array of { id, name } objects.
The problem: requests can finish out of order
A user types “rea” and then “react”. The application starts request A for the first query and request B for the second. If B finishes first but A finishes later, an unconditional state update from A replaces the newer results. The UI now shows data for a query that is no longer current.
A fast local server can hide this bug. The issue is ordering, not necessarily a slow network or an unsuccessful response. Before writing the fix, define the invariant: only the currently active effect may publish results, errors or loading-state changes.
Define the behaviour before the implementation
For an empty or whitespace-only query, show no results and do not make a request. For a non-empty query, show a loading state. Treat a non-success HTTP status as an error. When the query changes or the component unmounts, prevent the old request from publishing state.
This version clears previous results when starting a new request. Keeping previous results is another valid design, but then the UI should make it clear that they belong to an earlier query. Cancellation and state correctness are separate concerns: stopping unnecessary work is useful, while preventing stale state is essential.
A cleanup guard and an abort signal
Each effect invocation owns a local active flag and an AbortController. Cleanup marks that invocation inactive and aborts its fetch. Before publishing any result, the effect checks the flag. This also protects against callbacks that arrive after cleanup despite cancellation.
fetch does not reject solely because a server returns an HTTP error status, so the code checks response.ok. The catch branch displays a useful failure state only while the effect is active. The finally branch is guarded too, because an old request should not turn off the current request’s loading indicator.
import { useEffect, useState } from "react";
export default function SearchResults({ query }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
const controller = new AbortController();
const term = query.trim();
setItems([]);
setError("");
setLoading(Boolean(term));
if (!term) return () => { active = false; };
async function load() {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(term)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error("Request failed");
const data = await response.json();
if (!Array.isArray(data)) throw new Error("Invalid response");
if (active) setItems(data);
} catch {
if (active) setError("Could not load results. Try again.");
} finally {
if (active) setLoading(false);
}
}
load();
return () => {
active = false;
controller.abort();
};
}, [query]);
if (loading) return <p role="status">Loading…</p>;
if (error) return <p role="alert">{error}</p>;
return <ul>{items.map(item =>
<li key={item.id}>{item.name}</li>
)}</ul>;
}Try it yourself: Build a test endpoint or mock fetch response; /api/search is an example contract, not an endpoint provided by InterviewPitch. Validate each item’s shape in production, not only the outer array.
Test the ordering, not just the happy path
Use deferred promises so you control completion. Start query A, change to B, resolve B with a distinctive name, and finally resolve A with a different name. The visible final result must still belong to B. Repeat with A rejecting after B succeeds; the old error must not replace the current results.
Test an empty query, a non-success HTTP response, malformed JSON, a query change during loading and unmounting before completion. Also run in development Strict Mode, where React performs an extra setup-and-cleanup cycle to reveal missing cleanup. The implementation should remain correct under that cycle.
What this fix does not solve
A cleanup guard does not cache results, debounce typing or share requests between components. Those are different requirements. Debouncing reduces the number of requests but does not by itself prevent out-of-order responses. A cache also needs rules for staleness and cache keys.
Effects run on the client. For an application needing server-rendered data, routing integration or preloading, consider the data-loading tools supplied by its framework. Do not add every production concern to a small interview solution; identify the limits and explain where you would extend the design.
How to explain your answer in an interview
A strong explanation names the race, states the invariant and shows why cleanup prevents the old invocation from writing. A stronger answer guards loading and errors as well as results, explains the HTTP-status check and proposes a deterministic reversed-order test.
A weak answer only says “use async/await” or “add a timeout”. Neither establishes ownership of the state update. Practise the explanation without code first: the latest query owns the UI, and cleanup retires the previous owner. Then walk through one timeline to show that the implementation enforces it.
References and further study
Check language and framework details against the documentation for the version you use.