Review

SporeShelf herbarium search: deferred ranker + pending UI

The regional mycological society's field app freezes while volunteers type Latin binomials into the herbarium finder — every keystroke re-ranks ~40k specimen cards on the main thread. Keep the text input urgent, push the query used by the ranker through startTransition + useDeferredValue, and surface a pending affordance (opacity + aria-busy) until the deferred results catch up.

React 19Tier 3useDeferredValuestartTransitionsearchconcurrent

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

app/herbarium/SpecimenSearch.tsx+47-2
1+"use client";
2+
3+import {
4+ useDeferredValue,
5+ useMemo,
6+ useState,
7+ useTransition,
8+} from "react";
9+import { rankSpecimens, type Specimen } from "./rankSpecimens";
110
11+export function SpecimenSearch({ catalog }: { catalog: Specimen[] }) {
12+ const [input, setInput] = useState("");
13+ const [query, setQuery] = useState("");
14+ const [isPending, startTransition] = useTransition();
15+ const deferredQuery = useDeferredValue(query);
16+ const isStale = isPending || query !== deferredQuery;
217
18+ const hits = useMemo(
19+ () => rankSpecimens(catalog, deferredQuery),
20+ [catalog, deferredQuery],
21+ );
322
4- // previous: filter on every keystroke with no concurrent deferral
5- // const hits = rankSpecimens(catalog, input);
623
24+ return (
25+ <div className="specimen-search">
26+ <label htmlFor="spore-q">Search specimens</label>
27+ <input
28+ id="spore-q"
29+ value={input}
30+ onChange={(e) => {
31+ const next = e.target.value;
32+ setInput(next);
33+ startTransition(() => setQuery(next));
34+ }}
35+ placeholder="e.g. Boletus edulis"
36+ autoComplete="off"
37+ />
38+ <ul
39+ className={isStale ? "results is-pending" : "results"}
40+ aria-busy={isStale}
41+ aria-live="polite"
42+ >
43+ {hits.map((h) => (
44+ <li key={h.id}>
45+ <em>{h.latin}</em> — {h.common}
46+ </li>
47+ ))}
48+ </ul>
49+ </div>
50+ );
51+}