This is part of a series I like to think of as Real Coding Problems, Simple Fixes.
No fancy architecture. No interview puzzle. Just the kind of bug that shows up while building a normal app.
Today we are fixing a very common search bug:
The user types a new search term, but the UI sometimes shows results from an older search.
If you have built a search box, autocomplete field, customer lookup, product filter, user picker, or admin dashboard table, you have probably been close to this bug.
It usually does not happen every time. That is what makes it annoying. On your machine, with fast internet, everything may look fine. Then someone uses the app on a slower network and suddenly the search results feel haunted.
Let's make that bug visible, then fix it properly.
The Real Problem
Imagine you are building a customer lookup for a support dashboard.
The support agent starts typing a customer name:
a
am
amy
Your React component sends a request for each value:
GET /api/customers?search=a
GET /api/customers?search=am
GET /api/customers?search=amy
You probably expect the responses to come back in the same order.
But the internet does not promise that.
The request for amy might finish first. Then the older request for a might finish last. If your component blindly updates state whenever any response returns, the screen can end up showing results for a even though the input says amy.
That is the bug.
In simple words:
The latest thing the user typed is not always the latest response your app receives.
The Broken Version
Here is a small version of the problem.
import { useEffect, useState } from "react";
export default function CustomerSearch() {
const [query, setQuery] = useState("");
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!query.trim()) {
setCustomers([]);
return;
}
async function searchCustomers() {
setLoading(true);
setError("");
try {
const response = await fetch(`/api/customers?search=${query}`);
const data = await response.json();
setCustomers(data.customers);
} catch (err) {
setError("Could not load customers.");
} finally {
setLoading(false);
}
}
searchCustomers();
}, [query]);
return (
<section>
<label htmlFor="customer-search">Search customers</label>
<input
id="customer-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Type a customer name..."
/>
{loading && <p>Searching...</p>}
{error && <p>{error}</p>}
<ul>
{customers.map((customer) => (
<li key={customer.id}>{customer.name}</li>
))}
</ul>
</section>
);
}
At first glance, this looks reasonable.
When query changes, we fetch customers. When the response arrives, we put the customers into state.
The hidden problem is that every request is allowed to update the same customers state, even if that request is old news by the time it finishes.
Why This Happens
React is not doing anything weird here.
The bug comes from timing.
Let's say the user types quickly:
Search 1: "a" -> slow response
Search 2: "am" -> medium response
Search 3: "amy" -> fast response
The response order might be:
"amy" returns first
"am" returns second
"a" returns last
If all three responses call setCustomers, the oldest search can win just because it finished last.
That kind of bug is called a race condition.
You do not need to memorize the term. Just remember the practical version:
Multiple async tasks are trying to update the same screen, and the wrong one finishes last.
First Fix: Debounce the Input
Before we cancel old requests, we should also avoid creating too many requests.
If the user types a, am, amy quickly, we probably do not need to search after every single keypress. We can wait until the user pauses for a moment.
That is called debouncing.
Here is a small reusable hook:
import { useEffect, useState } from "react";
export function useDebounce(value, delay = 400) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timerId);
};
}, [value, delay]);
return debouncedValue;
}
Now React will wait 400 milliseconds after the latest change before updating debouncedValue.
That means this:
a
am
amy
can become one actual search request instead of three.
But there is an important detail:
Debounce reduces the number of requests. It does not guarantee old requests can never update your UI.
If a request has already started, debouncing will not magically cancel it.
So debounce is helpful, but it is not the complete fix.
The Proper Fix: Cancel the Old Request
The browser gives us a built-in way to cancel a fetch request: AbortController.
The idea is simple:
- Create an
AbortControllerfor the current request. - Pass its
signaltofetch. - When the effect cleans up, call
abort(). - Ignore the abort error because it is expected.
Here is the fixed component.
import { useEffect, useState } from "react";
import { useDebounce } from "./useDebounce";
export default function CustomerSearch() {
const [query, setQuery] = useState("");
const [customers, setCustomers] = useState([]);
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
const searchTerm = debouncedQuery.trim();
if (!searchTerm) {
setCustomers([]);
setStatus("idle");
setError("");
return;
}
const controller = new AbortController();
async function searchCustomers() {
setStatus("loading");
setError("");
try {
const response = await fetch(
`/api/customers?search=${encodeURIComponent(searchTerm)}`,
{
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error("Search request failed");
}
const data = await response.json();
setCustomers(data.customers ?? []);
setStatus("success");
} catch (err) {
if (err.name === "AbortError") {
return;
}
setCustomers([]);
setStatus("error");
setError("Could not load customers. Please try again.");
}
}
searchCustomers();
return () => {
controller.abort();
};
}, [debouncedQuery]);
return (
<section>
<label htmlFor="customer-search">Search customers</label>
<input
id="customer-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Type a customer name..."
/>
{status === "loading" && <p>Searching...</p>}
{status === "error" && <p>{error}</p>}
{status === "success" && customers.length === 0 && (
<p>No customers found.</p>
)}
<ul>
{customers.map((customer) => (
<li key={customer.id}>
<strong>{customer.name}</strong>
<span> - {customer.email}</span>
</li>
))}
</ul>
</section>
);
}
Now when debouncedQuery changes, React runs the cleanup function for the previous effect.
That cleanup calls:
controller.abort();
So the older request is told:
You are no longer needed.
When fetch is aborted, it throws an error with the name AbortError. That is why we check this:
if (err.name === "AbortError") {
return;
}
An aborted request is not a real failure from the user's point of view. It just means the user typed something newer.
What the Fixed Flow Looks Like
With the fixed version, the flow is cleaner:
User types "a"
Wait briefly
Start request for "a"
User changes input to "amy"
React cleans up the old effect
Request for "a" is aborted
Wait briefly
Start request for "amy"
Only the useful result updates the UI
This gives you two benefits:
- You send fewer requests because of debounce.
- You stop outdated requests because of
AbortController.
That combination is much stronger than debounce alone.
A Tiny Mock API for Testing
If you want to test this locally, you can create an endpoint that responds after a random delay.
This makes the bug easier to reproduce.
Here is a tiny Express-style example:
const express = require("express");
const app = express();
const customers = [
{ id: 1, name: "Amy Chen", email: "amy@example.com" },
{ id: 2, name: "Amir Khan", email: "amir@example.com" },
{ id: 3, name: "Ana Lopez", email: "ana@example.com" },
{ id: 4, name: "Brian Stone", email: "brian@example.com" },
];
app.get("/api/customers", async (req, res) => {
const search = String(req.query.search || "").toLowerCase();
const delay = Math.floor(Math.random() * 1500);
await new Promise((resolve) => setTimeout(resolve, delay));
const matches = customers.filter((customer) =>
customer.name.toLowerCase().includes(search)
);
res.json({ customers: matches });
});
app.listen(3000, () => {
console.log("API running on http://localhost:3000");
});
The random delay is useful because real networks are not perfectly predictable either.
Try the broken component with this API and type quickly. You may see old results appear. Then switch to the fixed version and test again.
Common Mistakes to Avoid
Mistake 1: Only using debounce
Debounce is good for reducing noise.
But if a request has already started, debounce does not stop it.
Use debounce to avoid unnecessary requests. Use AbortController to cancel requests that are no longer relevant.
Mistake 2: Showing an error when a request was canceled
If the user types a new search term, canceling the old request is normal.
Do not show:
Something went wrong.
just because an old request was aborted.
Check for AbortError and quietly return.
Mistake 3: Forgetting empty input
If the user clears the search box, reset the results.
if (!searchTerm) {
setCustomers([]);
setStatus("idle");
setError("");
return;
}
This avoids showing old results when the input is empty.
Mistake 4: Not encoding the search term
Search text can contain spaces, symbols, and special characters.
Use encodeURIComponent before putting user input into a query string:
`/api/customers?search=${encodeURIComponent(searchTerm)}`
Could You Use a Library Instead?
Yes.
In a bigger app, you might use something like TanStack Query, SWR, or another data-fetching library. Those tools can help with caching, request state, retries, background refreshes, and more.
But even if you use a library later, it is still worth understanding this bug.
Because the root idea appears everywhere:
When async work finishes, make sure it is still relevant before updating the UI.
That applies to search boxes, file uploads, page filters, route changes, modal forms, and many other everyday features.
Final Takeaway
The bug is not that React is slow.
The bug is not that useEffect is broken.
The bug is that network responses can arrive in a different order than the requests were sent.
For search inputs, a practical fix is:
Debounce the input
+ Cancel outdated requests
+ Ignore expected abort errors
+ Reset state for empty input
That gives users a search box that feels stable, even when the network is not.
References
- React docs: useEffect and race conditions
- React docs: You Might Not Need an Effect
- MDN: AbortController
- MDN: AbortSignal
Research note: the article topic was inspired by recurring developer discussions on Reddit about React async race conditions, useEffect cleanup confusion, debounce limitations, and whether AbortController is a practical tool. The examples, wording, and structure here are original.
Top comments (0)