Tags: Storing, Attaching & Filtering by Tag on Books
Personal Catalogue: React & Firebase
Chapter 5 · Tags: Storing, Attaching & Filtering by Tag on Books
Chapter 4 only ever wrote tags once — typed as comma-separated text, converted to an array, sent along with a brand-new Book. Nothing since then has touched a book's own tags after it already exists. This chapter builds that: adding and removing individual tags safely under real concurrent edits, a genuine gap this app has that the MongoDB sibling's own discriminator models close automatically, and filtering the catalogue by tag.
Firestore Already Has an Atomic Answer
The MongoDB sibling's own Chapter 5 built a real lost-update race first, then fixed it with
$addToSet/$pull — atomic array operators that let MongoDB itself decide what's
currently in an array at the exact moment an update runs, rather than trusting a client's own possibly-stale
snapshot. Firestore's client SDK ships the identical idea, directly usable from React with no backend route
at all: arrayUnion() and arrayRemove().
The naive approach still fails the same way first, though, and it's worth seeing why before reaching for the fix:
| Time | Tab A | Tab B | Tags actually saved in Firestore |
|---|---|---|---|
| t0 | Loads the book, sees ['Python', 'Programming'] | Loads the book, sees ['Python', 'Programming'] | ['Python', 'Programming'] |
| t1 | Adds 'Web Development' locally, writes ['Python', 'Programming', 'Web Development'] | — (still holding its own t0 snapshot) | ['Python', 'Programming', 'Web Development'] |
| t2 | — | Adds 'FastAPI' to its own stale local copy, writes ['Python', 'Programming', 'FastAPI'] | ['Python', 'Programming', 'FastAPI'] — Tab A's addition is gone, with no error anywhere |
Exactly the same failure the MongoDB sibling reproduced, for exactly the same reason: nothing about Firestore made this naive code race-safe on its own, because the race lives in the client's own logic, not in either database. The fix is equally direct:
Rerunning the exact two-tab timeline through arrayUnion() instead gives the same correct result
the MongoDB sibling found: Firestore itself resolves each union against whatever the array holds the instant
that specific write is applied, not against either tab's own memory of it. Both additions land — the final
array is ['Python', 'Programming', 'Web Development', 'FastAPI'].
addTag(bookId, 'Python') on a book that already has that tag is a genuine no-op — Firestore's
own arrayUnion() only adds a value if an equal one isn't already present in the array, the exact
same behavior $addToSet gives the MongoDB sibling. A plain array overwrite with a manually
appended value would add it again regardless, producing a real duplicate.
The Real Gap: Nothing Scopes This to Books
The MongoDB sibling's own Chapter 5 confirmed something worth revisiting here directly: calling
Book.findByIdAndUpdate() against a real Cd's own _id returns a genuine 404, because
that discriminator model only ever matches documents whose stored itemType is
'Book'. addTag() above has no equivalent protection at all — it's a plain
updateDoc() call against whatever document ID it's given, with no concept of "the Book model"
to route through in the first place, because Firestore has no discriminator models of any kind.
Calling it against a real Cd's own document ID proves this directly:
hasValidBaseFields()
still passes (the Cd's title and itemType are unchanged), hasTypeRequiredFields() still passes
(the Cd's own required artist field is still present), and hasValidResolution() was
never about tags at all. None of the three rules built so far check for a field that shouldn't be there —
exactly the same category of gap Chapter 4 named for a leftover author field, now showing up a
second time on a completely different field.
Closing It: A Real Rule Addition
The fix is a fourth rule function, added to firestore.rules alongside the three from Chapter 3,
checking specifically that a tags field is never present unless the document's own
itemType is 'Book':
Deployed via firebase deploy --only firestore:rules, the identical addTag() call
against the same Cd document now fails the way the MongoDB sibling's own version always did:
tagsOnlyOnBooks() exists because this chapter went looking
for the MongoDB sibling's own discriminator-scoping guarantee and found it genuinely missing, not because it
was anticipated back in Chapter 3.
A Small Tag Editor Component
A focused component — not the full book detail page Chapter 7 builds — renders each current tag as a
removable pill and offers a small input for adding a new one, calling addTag/removeTag
directly, with no fetch call or backend route anywhere in between:
Unlike the MongoDB sibling's own version, which reads its updated tags array straight back from
the server's JSON response after each request, this component updates its local state directly, since
updateDoc() resolves with no response body at all — the local update mirrors exactly what
arrayUnion/arrayRemove just did, using a real Set to guarantee the same
no-duplicate guarantee locally that Firestore already enforces server-side.
Filtering the Catalogue by Tag
Firestore's own array-contains operator does exactly what the MongoDB sibling's own plain
{ tags: tag } filter does — matching any document whose array field contains a given value —
combined here with an explicit itemType filter, for the same reason the sibling course states its
own equivalent condition plainly: no other item type ever has a tags field to match against, but
saying so directly keeps the intent obvious to whoever reads this query next.
itemType) with an array-contains filter is a real
compound query, and Firestore often needs a composite index to run one efficiently. The first time this
exact combination runs against a real project, expect a real console error containing a direct link to
auto-create the missing index — not a bug in the query itself, just Firestore's own real, one-time setup step
for this specific filter combination.
Case Sensitivity — the Same Real Limitation
arrayUnion/arrayRemove and array-contains all compare by exact value
equality, the same as MongoDB's own $addToSet. 'python' and 'Python'
are two genuinely different strings here too, and both would end up stored as separate tags on the same book.
This course doesn't normalize casing on the way in either — a real, shared limitation across both variants,
not something specific to either database.
Hands-On Exercises
Build addTag/removeTag using arrayUnion/arrayRemove and the TagEditor component, then add and remove several tags on a real Book, confirming after each step via the Firebase console that duplicates are never created and removed tags are genuinely gone.
📄 View solutionReproduce the lost-update scenario using two real sequential calls both built from the same original tags snapshot, sent through the naive read-modify-write approach — then repeat the same two-call scenario using arrayUnion instead, and compare the two final results.
📄 View solutionCall addTag() against a real Cd's or Dvd's own document ID both before and after deploying the tagsOnlyOnBooks rule, and explain precisely why the outcome differs — including why neither of the two earlier rule functions from Chapter 3 already caught this.
📄 View solutionChapter 5 Quick Reference
- arrayUnion() / arrayRemove() — Firestore's own direct equivalent to $addToSet/$pull, atomic and dedupe-aware, callable straight from React with no backend route
- The naive read-modify-write race — reproduced identically to the MongoDB sibling; fixed the same way, by never trusting a client's own held snapshot
- The real gap — no discriminator model means nothing stops a tag operation from landing on a Cd or Dvd document; confirmed by actually adding one
- The fix — a fourth Security Rules function, tagsOnlyOnBooks(), added to the same rules file Chapter 3 started
- Filtering — where("itemType","==","Book") + where("tags","array-contains",tag); may need a one-time composite index
- Shared limitation — tag matching is case-sensitive on both variants; neither normalizes casing
- Next chapter: Search — Firestore's own real query limitations, compared directly against MongoDB's richer query language