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:

// A naive, read-modify-write approach — works, but has a real problem below import { doc, getDoc, updateDoc } from "firebase/firestore"; async function addTagNaively(bookId, newTag) { const ref = doc(db, "items", bookId); const snap = await getDoc(ref); const updatedTags = [...snap.data().tags, newTag]; await updateDoc(ref, { tags: updatedTags }); }
TimeTab ATab BTags actually saved in Firestore
t0Loads the book, sees ['Python', 'Programming']Loads the book, sees ['Python', 'Programming']['Python', 'Programming']
t1Adds '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:

// tagOperations.js import { doc, updateDoc, arrayUnion, arrayRemove } from "firebase/firestore"; import { db } from "./firebase"; export async function addTag(bookId, tag) { return updateDoc(doc(db, "items", bookId), { tags: arrayUnion(tag) }); } export async function removeTag(bookId, tag) { return updateDoc(doc(db, "items", bookId), { tags: arrayRemove(tag) }); }

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'].

arrayUnion() Also Dedupes
Calling 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:

await addTag(realCdDocumentId, "Anything"); // succeeds — no error, no rejection // the Cd document now has a real tags field it was never supposed to have
The Current Security Rules Don't Catch This Either
Checking this write against Chapter 3's own rules confirms why it wasn't stopped: 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':

// firestore.rules (updated from Chapter 3) function tagsOnlyOnBooks(data) { return !('tags' in data) || data.itemType == 'Book'; } match /items/{itemId} { allow read: if true; allow write: if hasValidBaseFields(request.resource.data) && hasTypeRequiredFields(request.resource.data) && hasValidResolution(request.resource.data) && tagsOnlyOnBooks(request.resource.data); }

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:

await addTag(realCdDocumentId, "Anything"); // FirebaseError: Missing or insufficient permissions.
A Rule Grew Because a Real Bug Was Found, Not in Advance
This is the honest shape this course keeps taking: Firestore's own lack of a schema layer doesn't announce its gaps up front the way a missing Mongoose field would — it takes an actual reproduced bug, on a specific field, to know a specific rule needs adding. 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:

// src/components/TagEditor.jsx import { useState } from 'react'; import { addTag, removeTag } from '../tagOperations'; export default function TagEditor({ bookId, initialTags, onChange }) { const [tags, setTags] = useState(initialTags); const [newTag, setNewTag] = useState(''); async function handleAdd() { if (!newTag.trim()) return; const tag = newTag.trim(); await addTag(bookId, tag); const updated = [...new Set([...tags, tag])]; setTags(updated); setNewTag(''); onChange?.(updated); } async function handleRemove(tag) { await removeTag(bookId, tag); const updated = tags.filter((t) => t !== tag); setTags(updated); onChange?.(updated); } return ( <div> {tags.map((tag) => ( <span key={tag} className="tag-pill"> {tag} <button onClick={() => handleRemove(tag)}>&times;</button> </span> ))} <input value={newTag} onChange={(e) => setNewTag(e.target.value)} placeholder="Add a tag" /> <button onClick={handleAdd}>Add</button> </div> ); }

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.

import { collection, query, where, getDocs } from "firebase/firestore"; export async function findBooksByTag(tag) { const q = query( collection(db, "items"), where("itemType", "==", "Book"), where("tags", "array-contains", tag) ); const snapshot = await getDocs(q); return snapshot.docs.map((d) => ({ id: d.id, ...d.data() })); }
The First Real Run of This Query May Ask for an Index
Combining an equality filter (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

Exercise 1

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 solution
Exercise 2

Reproduce 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 solution
Exercise 3

Call 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 solution

Chapter 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