Code review · TypeScript

Review a React Search Component

Medium Code review Most asked

This React component searches products as the user types and refreshes a "results updated" clock every second.

Review it: list the problems, what the user would see, and how to fix each one.

The code to review

There are 6 issues to find.

import { useEffect, useState } from "react";

type Product = { id: number; name: string };

export function ProductSearch() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Product[]>([]);
  const [now, setNow] = useState(Date.now());

  useEffect(() => {
    fetch(`/api/products?q=${query}`)
      .then((r) => r.json())
      .then((data) => setResults(data));
  }, [query]);

  useEffect(() => {
    setInterval(() => setNow(Date.now()), 1000);
  });

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <small>Updated {new Date(now).toLocaleTimeString()}</small>
      <ul>
        {results.map((p, i) => (
          <li key={i}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

Write your review

Loading the editor… (JavaScript is required to run code in your browser.)

The issues and how to fix them

Show the issues

1. Out-of-order responses show stale results

Major, line 10-14

Typing "pho" then "phone" starts two requests. If the first one finishes last, the list shows results for "pho" while the box says "phone". Nothing cancels or ignores the old request.

Fix: Return a cleanup that aborts the request (AbortController) or sets an ignore flag, so only the latest query can set state. A data library (TanStack Query, SWR) handles this for you.

2. Interval is never cleared, and the effect has no dependency array

Major, line 16-18

The effect runs after every render and never clears its interval. Each tick re-renders, which starts another interval, so the number of timers (and re-renders) grows every second until the tab slows down.

Fix: Run it once with [] and return () => clearInterval(id).

3. Query is not URL-encoded

Major, line 11

A search for a&b or 50% sends a different query or a malformed URL.

Fix: Use encodeURIComponent(query) or new URLSearchParams({ q: query }).

4. A request on every keystroke, including the empty string

Minor, line 10-14

Ten keystrokes make ten requests, and the empty query fetches everything.

Fix: Debounce (about 250 ms) and skip queries shorter than 2 characters.

5. Array index used as the list key

Minor, line 26

When results change, React reuses list items by position, which can keep the wrong item's state (focus, inputs) on the wrong product.

Fix: Use the stable ID: key={p.id}.

6. No error or loading handling

Minor, line 11-13

A failed request or a non-2xx response is silently ignored (or crashes on r.json()).

Fix: Check r.ok, keep error and loading state, and render them.

Effects that start async work or timers must clean up after themselves. Interviewers expect you to spot the race (stale responses) and the leaking interval; mentioning that a data-fetching library solves the race is a good senior-level remark.

Fixed version

import { useEffect, useState } from "react";

type Product = { id: number; name: string };

export function ProductSearch() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Product[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [now, setNow] = useState(Date.now());

  useEffect(() => {
    if (query.trim().length < 2) return setResults([]);
    const controller = new AbortController();
    const t = setTimeout(() => {
      fetch(`/api/products?${new URLSearchParams({ q: query })}`, { signal: controller.signal })
        .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
        .then((data: Product[]) => { setResults(data); setError(null); })
        .catch((e) => { if (e.name !== "AbortError") setError(String(e.message)); });
    }, 250); // debounce
    return () => { clearTimeout(t); controller.abort(); }; // older queries can never win
  }, [query]);

  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search products" />
      <small>Updated {new Date(now).toLocaleTimeString()}</small>
      {error && <p role="alert">{error}</p>}
      <ul>
        {results.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </div>
  );
}

Follow-up questions

  • How does React 18 Strict Mode help you find missing cleanups?
  • When would you move this state into the URL?