Data Modeling in Firestore

Food Tracker (React + Firebase)

Chapter 2 · Data Modeling in Firestore

Chapter 1 named Firestore as this course's own database, in contrast to the SQL every sibling course uses. This chapter designs the Pantry Item around it properly — and the honest answer up front is that Firestore's document model doesn't just store the same data differently, it changes what "the schema" even means.

The Relational Shape (What the Other Three Courses Use)

Food Tracker (FastAPI), Food Tracker (Django), and Food Tracker (React + Express) all model a Pantry Item as one row in a single SQL table — id, name, barcode, category, a nullable expiry_date, a status column, added_at, and a nullable used_at. The database enforces column types and nullability up front, at write time — a row that violates the schema is rejected before it's ever stored.

Firestore's Document Model

Firestore stores data as collections of documents — each document a JSON-like object whose fields can be strings, numbers, booleans, timestamps, arrays, or nested maps. Critically, Firestore itself enforces no schema at all: nothing stops one document in a collection from having fields another document in the same collection lacks entirely. This is schema-on-read rather than SQL's schema-on-write — the database doesn't validate shape at all; whatever validates it is the application code, or nothing.

For this app: one top-level items collection, one document per Pantry Item, with fields name, barcode, category, expiryDate, status, addedAt, and usedAt.

A Real Firestore Document

// items/8xJ2kLpQmN4vR7wZ (an active item) { name: "Greek Yogurt, 500g", barcode: "5901234123457", category: "dairy", status: "active", expiryDate: Timestamp(2026-08-14), addedAt: Timestamp(2026-08-02), } // items/qT9nB3fXcW1yH6dP (a used item — expiryDate omitted entirely) { name: "Sourdough Loaf", barcode: "5901234654321", category: "bakery", status: "used", addedAt: Timestamp(2026-07-28), usedAt: Timestamp(2026-07-30), }

Notice the used item doesn't set expiryDate to null — it omits the field entirely. This is a deliberate modeling choice, not an oversight: a Firestore range query (where("expiryDate", "<", someDate)) only ever matches documents where that field actually exists and is a comparable type. A document missing the field is automatically excluded from the results — quietly and correctly — which is exactly the behavior the expiry-alerts query in Chapter 7 depends on. SQL's NULL behaves differently in comparisons (a `NULL` value in a `WHERE expiry_date < X` clause is neither true nor false, and is excluded for a different underlying reason) — the end result looks similar here, but it's worth knowing the two databases arrive at it through genuinely different mechanics.

Document IDs: Not the Barcode

A tempting shortcut is using the barcode itself as the document ID, since it's already a natural unique identifier for the product. Resist it: the same product (the same barcode) can be bought — and tracked — more than once, each purchase with its own expiry date and its own lifecycle. The document ID needs to identify one purchased instance, not one product. Let Firestore auto-generate the document ID, and store the barcode as an ordinary field instead, exactly as shown above.

Use Firestore's Timestamp Type, Not Date Strings

Firestore has a native Timestamp type specifically for dates — use it for expiryDate, addedAt, and usedAt, rather than storing an ISO date string. Range queries, sorting, and Chapter 7's own "expiring within N days" logic all rely on genuine timestamp comparison, not string comparison that happens to work for well-formatted ISO strings but isn't a real date comparison at all.

Firestore vs. SQL, Side by Side

SQL (the other three courses)Firestore (this course)What's genuinely different
Table + column schemaCollection of documents, no enforced schemaSchema-on-write vs. schema-on-read
Primary key (often auto-increment)Auto-generated document IDSimilar in spirit; Firestore IDs are opaque strings, not sequential integers
Nullable column (NULL)Field simply absent from the document"No value" is modeled as absence, not a special null marker
WHERE expiry_date < Xwhere("expiryDate", "<", X)Documents missing the field are excluded automatically, same practical outcome via a different mechanism

Querying Firestore: A First Look

import { collection, query, where, getDocs } from "firebase/firestore"; import { db } from "./firebase"; const activeItemsQuery = query( collection(db, "items"), where("status", "==", "active") ); const snapshot = await getDocs(activeItemsQuery); const activeItems = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
The relational-vs-document motif, one more time
Comparative Linux Distributions, `cp1`, and MongoDB Fundamentals 5's own embedding-vs-referencing material have all touched some version of this same underlying question on this site: relational structure vs. flexible document structure, and what each trades away. This app's own data is honestly a fairly easy case for a document database — flat, no real nested relationships yet — so the interesting document-modeling decisions are still ahead, not here: Chapter 10's recipe results are exactly the kind of data (an ingredient list nested inside a recipe) where embedding-vs-referencing actually becomes a real design question again.
Get some of SQL's safety back, voluntarily
Firestore won't stop a typo'd field name or an inconsistent shape — so define a single TypeScript interface (or at minimum, a shared constants file) for what an Item document actually looks like, and use it at every single write site in the app. It's optional and unenforced by the database, which is exactly why skipping it is easy to regret later.
A silent typo can create a second, invisible field
Writing expiryDate in one part of the app and expiry_date in another doesn't raise an error — Firestore happily stores both as two separate fields on the same document, and neither Chapter 7's alerts query nor anything else will notice until items mysteriously stop showing up as expiring. This class of bug has no SQL equivalent, since a misspelled column name there fails immediately and loudly. The shared-interface habit from the tip box above is the real defense against it.

Where This Course Is Headed

Barcode lookup via a Cloud Function, the camera-scanning React component, direct client writes to Firestore, Security Rules as the real gatekeeper, expiry alerts built on exactly the query shown above, item history and its search limitations, marking items used, recipe lookup, Firebase Authentication, deployment, and a capstone tying every chapter together.

Hands-On Exercises

Exercise 1

Explain the difference between a field being "absent" in Firestore and a column being NULL in SQL. Why does a used item's document simply omit expiryDate rather than setting it to null, and how does that choice affect Chapter 7's own expiry-alerts query?

📄 View solution
Exercise 2

Explain why this chapter argues against using the barcode itself as a Firestore document ID, and what should be used instead.

📄 View solution
Exercise 3

This chapter says Firestore's schema-on-read flexibility is a genuine trade-off, not a free win. What cost does it push onto the application that SQL's schema-on-write avoids, and how does the tip box's shared-interface suggestion partially address it?

📄 View solution

Chapter 2 Quick Reference

  • Collection/document model — one items collection, one document per Pantry Item, no enforced schema
  • Field absence, not NULL — a used item simply omits expiryDate; range queries exclude documents missing the field automatically
  • Document ID — auto-generated, never the barcode (which identifies a product, not a purchased instance)
  • Firestore Timestamp — always for dates, never a plain string
  • This chapter's own throughline: schema-on-read trades database-enforced consistency for flexibility — a cost the app's own code has to pick up instead
  • Next chapter: Barcode Lookup via a Cloud Function