Item History & Live Search-as-You-Type

Food Tracker (React + Firebase)

Chapter 8 · Item History & Live Search-as-You-Type

Every item ever added lives in one list — active items with a real expiry date, used items without one, exactly as Chapter 2 modeled them. This chapter makes that combined history searchable in real time, and runs straight into the most honest limitation Firestore has.

Firestore Has No Full-Text Search

Firestore's query operators are genuinely limited: == for exact match, range operators for ordering, array-contains/in for membership — and nothing resembling SQL's LIKE '%term%', and no built-in full-text search at all. This isn't a missing feature to work around quietly; it's a real, well-known consequence of the same document-database tradeoff Chapter 2 introduced.

The Prefix-Query Trick — and Its Real Limits

const q = query( collection(db, "items"), where("name", ">=", searchTerm), where("name", "<", searchTerm + "") );

This genuinely works — for an exact, case-sensitive prefix match. The trailing value appended after searchTerm in the second where() call is the Unicode private-use character U+F8FF, which sorts after virtually every normal character — appending it is what makes the range capture every string starting with searchTerm and nothing past it. Typing "Gr" matches "Greek Yogurt". It has two real limits worth being honest about: it is case-sensitive ("gr" will not match "Greek Yogurt" at all), and it only matches from the start of the string — searching "yogurt" will not find "Greek Yogurt" using this technique alone.

Fixing Case-Sensitivity: a nameLower Shadow Field

Firestore has no query-time equivalent of SQL's LOWER() — the only fix is storing a second, already-lowercased field alongside the original, and querying against that instead. Chapter 5's add-item flow needs one more field:

await addDoc(collection(db, "items"), { name, nameLower: name.toLowerCase(), // new — used for case-insensitive search // ...the rest, unchanged from Chapter 5 });

Chapter 6's Security Rules need one more clause too — requiring nameLower to actually equal name.lower() at write time, the same "voluntary schema, enforced by rules" pattern that chapter already established for every other field.

When Prefix Search Genuinely Isn't Enough

Neither trick above solves mid-word or typo-tolerant search. Two real, honest options exist once that's actually needed:

  • Filter client-side. Pull the (realistically small, single-user) item history once, and filter it in plain JavaScript on every keystroke — no per-keystroke network call, no Firestore query limitation to work around at all.
  • Mirror into a dedicated search service. Algolia, Typesense, or Meilisearch, kept in sync with Firestore via a Cloud Function trigger on every document write — the genuinely scalable answer, and genuinely more infrastructure than this app needs.

For a personal pantry tracker realistically holding a few hundred items at most, client-side filtering is the right call — named explicitly rather than left ambiguous. A dedicated search service is the honest answer if this ever needed to scale to thousands of items per user, not something this course builds.

The Actual Implementation

function useItemHistory() { const [allItems, setAllItems] = useState([]); useEffect(() => { const q = query(collection(db, "items"), orderBy("addedAt", "desc")); getDocs(q).then(snap => setAllItems(snap.docs.map(d => ({ id: d.id, ...d.data() })))); }, []); return allItems; } function ItemHistorySearch() { const allItems = useItemHistory(); const [term, setTerm] = useState(""); const filtered = term ? allItems.filter(item => item.nameLower.includes(term.toLowerCase())) : allItems; return ( <> setTerm(e.target.value)} placeholder="Search items..." />
    {filtered.map(item =>
  • {item.name}{item.status === "active" ? ` — expires ${item.expiryDate.toDate().toLocaleDateString()}` : " (used)"}
  • )}
); }

Notice there's no debounce anywhere in this component. Debouncing exists to avoid firing a network request on every keystroke — but this approach loads the small dataset once and filters it entirely in memory afterward, so there's no per-keystroke network call to debounce in the first place.

A real tradeoff, not a workaround failure
Firestore's lack of native full-text search is a genuine consequence of the same document-database design Chapter 2 introduced, not a bug or an oversight. The right fix scales with the actual size of the problem — client-side filtering for a small personal dataset, a dedicated search service for a large or shared one. Knowing which one an app actually needs, rather than reaching for the more complex answer by default, is the real skill this chapter is teaching.
The shadow-field pattern generalizes
Any field ever needing case-insensitive querying in Firestore needs its own lowercase shadow field, following nameLower's own pattern exactly — there's no query-time function that does this for you, unlike SQL's LOWER().
Adding a shadow field after real data already exists is a real migration problem
If nameLower is added to the add-item flow only after the app already has real items in production, every document created before that change is missing the field entirely — and per Chapter 2's own "absence, not null" lesson, those documents simply won't be found by any search built against nameLower. Fixing this needs a one-time backfill script that reads every existing document, computes nameLower from its name, and writes it back. A schema-on-read database doesn't retroactively update old documents just because the application's own idea of the schema changed.

Where This Course Is Headed

Marking items used, recipe lookup for items nearing expiry, Firebase Authentication, deployment, and a capstone.

Hands-On Exercises

Exercise 1

Explain the two real limits of the `where(">=", term).where("<", term + "")` prefix trick, and what field this chapter adds — and why — to fix the case-sensitivity limit specifically.

📄 View solution
Exercise 2

Explain why this chapter's own client-side filtering approach needs no debounce, unlike a typical live-search feature hitting a network API on every keystroke. At what point would this chapter say client-side filtering stops being the right approach?

📄 View solution
Exercise 3

Explain why adding nameLower after the app already has real production data creates a genuine problem, connecting it back to Chapter 2's own "field absence, not null" lesson. What's the actual fix?

📄 View solution

Chapter 8 Quick Reference

  • No native full-text search — a genuine Firestore limitation, not a missing feature to route around silently
  • Prefix trickwhere(">=",term).where("<",term+""); case-sensitive, prefix-only
  • nameLower — a lowercase shadow field, fixing case-sensitivity; needs a backfill if added after real data exists
  • Client-side filter vs. a search service — client-side is right for this app's realistic data volume; a service like Algolia is the honest answer only at real scale
  • No debounce needed here — the small dataset loads once; filtering afterward is pure in-memory JS, no per-keystroke network call
  • Next chapter: Marking Items Used