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
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:
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
- {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.
nameLower's own pattern exactly — there's no query-time function that does this for you, unlike SQL's LOWER().
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
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 solutionExplain 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 solutionExplain 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 solutionChapter 8 Quick Reference
- No native full-text search — a genuine Firestore limitation, not a missing feature to route around silently
- Prefix trick —
where(">=",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