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"; | |
| 1 | 10 | ||
| 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; | |
| 2 | 17 | ||
| 18 | + | const hits = useMemo( | |
| 19 | + | () => rankSpecimens(catalog, deferredQuery), | |
| 20 | + | [catalog, deferredQuery], | |
| 21 | + | ); | |
| 3 | 22 | ||
| 4 | - | // previous: filter on every keystroke with no concurrent deferral | |
| 5 | - | // const hits = rankSpecimens(catalog, input); | |
| 6 | 23 | ||
| 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 | + | } |