Tags
Personal Catalogue: React, Express & MongoDB
Chapter 5 · Tags
Chapter 4 handled tags exactly 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 tags after it already exists. This chapter builds that: adding and removing individual tags on an already-saved book, a real concurrency problem that shows up the moment more than one edit can happen at once, and filtering the whole catalogue down to books carrying a specific tag.
Tags Only Ever Live on Book
Chapter 2's own tags: [String] field exists on the Book discriminator schema alone — Cd, Dvd, and
Bluray were never given one. Exercise 2 of Chapter 4 already confirmed the practical consequence directly:
sending a tags field alongside a Cd or Dvd document gets silently dropped by Mongoose's own
strict mode before it's ever saved, the same way a Cd's leftover author field was. Every route
built in this chapter works with that constraint rather than fighting it — tag operations run through the
Book model specifically, never the generic Item model.
A Naive Way to Add a Tag
The most obvious approach reuses Chapter 3's own PUT /api/items/:id route exactly as it already
exists: fetch the book, add the new tag to its tags array in JavaScript, then PUT
the whole modified array back.
This works perfectly well for a single user editing one book at a time. The problem only shows up once two edits can genuinely overlap.
The Real Cost: A Lost Update Under Concurrent Edits
Two browser tabs, open on the same book, both currently showing tags: ['Python', 'Programming']:
| Time | Tab A | Tab B | Tags actually saved in MongoDB |
|---|---|---|---|
| t0 | Loads the book, sees ['Python', 'Programming'] | Loads the book, sees ['Python', 'Programming'] | ['Python', 'Programming'] |
| t1 | Adds 'Web Development' locally, PUTs ['Python', 'Programming', 'Web Development'] | — (still holding its own t0 snapshot) | ['Python', 'Programming', 'Web Development'] |
| t2 | — | Adds 'FastAPI' to its own stale local copy, PUTs ['Python', 'Programming', 'FastAPI'] | ['Python', 'Programming', 'FastAPI'] — Tab A's addition is gone, with no error anywhere |
Tab B never saw Tab A's edit, because Tab B's own local tags array was captured at t0 and never
refreshed. Its own PUT at t2 overwrites the entire array with what it believes the
correct full set of tags is — silently erasing 'Web Development' along the way. Nothing in Chapter 3's own
PUT route is broken; it's doing exactly what it was asked to do. The bug is in asking a client
to hold the full, current array in memory at all, across any real gap in time.
Atomic Tag Operations With $addToSet and $pull
The real fix moves the decision of "what's currently in the array" onto MongoDB itself, at the exact moment
the update runs — never onto a client's own possibly-stale snapshot. Two new routes, added into
routes/items.js alongside Chapter 3's existing ones:
Rerunning the exact two-tab timeline against these routes instead of the naive PUT gives a
genuinely different, correct result: Tab A's $addToSet for 'Web Development' and Tab B's
$addToSet for 'FastAPI' each tell MongoDB to modify whatever the array currently holds at the
moment that specific command runs — not whatever a client remembers it holding. Both additions land, and the
final array is ['Python', 'Programming', 'Web Development', 'FastAPI'], with nothing lost.
$addToSet only adds a value if it isn't already present in the array — adding
'Python' to a book that already has it is a genuine no-op, confirmed by checking that the
document's own updatedAt timestamp doesn't change. $push would add it again
regardless, producing a real duplicate entry. Tags have no reason to ever appear twice on the same book, so
$addToSet is the correct choice here specifically — not a stylistic preference.
PATCH /api/items/<a-real-cd-id>/tags/add against a Cd's own genuine, existing
_id returns a real 404 "Item not found" — not because that _id doesn't
exist (a plain GET /api/items/<same-id> confirms it does), but because
Book.findByIdAndUpdate() only ever matches documents whose stored itemType is
'Book'. This is exactly Chapter 2's own claim that "querying through a specific discriminator
model like Book filters automatically to just that type" — confirmed here on a write, not just a read.
$addToSet compares values by exact equality — 'python' and 'Python'
are two genuinely different strings as far as MongoDB is concerned, and both would end up stored as separate
tags on the same book. This course doesn't normalize casing on the way in; it's a real, known limitation
worth naming honestly rather than a problem this chapter solves.
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 the two routes above directly:
tags in this component's own local state is only ever set from whatever the server just
returned after a real add/remove call — never modified in place client-side before sending — so it can't
drift out of sync with the database the way the naive approach's stale snapshot could.
Filtering the Catalogue by Tag
Chapter 3's own GET /api/items route gets one small extension: an optional tag
query parameter, narrowing the result down to Books whose tags array contains that exact value.
A plain { tags: tag } query, without the itemType: 'Book' condition, would already
only ever match Book documents in practice — Cd, Dvd, and Bluray never have a tags array to
match against at all. Adding the condition explicitly isn't fixing a correctness gap; it just states the
real intent plainly, for whoever reads this route next.
Trying It End to End
Hands-On Exercises
Build both PATCH routes (add and remove) and the TagEditor component, then add and remove several tags on a real Book, confirming after each step via GET /api/items/:id that duplicates are never created and removed tags are genuinely gone.
📄 View solutionReproduce the lost-update scenario using two real sequential requests both built from the same original tags snapshot, sent through the naive PUT approach — then repeat the same two-request scenario using $addToSet instead, and compare the two final results.
📄 View solutionSend a PATCH .../tags/add request against a real Dvd's or Cd's own _id and explain why it returns a 404 even though that _id definitely exists in the items collection.
📄 View solutionChapter 5 Quick Reference
- Naive read-modify-write — fetch, modify the array in JS, PUT the whole thing back; works for one editor, loses data under a real, reproducible concurrent-edit race
- $addToSet — atomically adds a tag only if it isn't already present, with no client-held snapshot to go stale
- $pull — atomically removes a tag by value, same atomicity guarantee
- Discriminator auto-filtering confirmed on writes — Book.findByIdAndUpdate() against a real Cd's own _id returns a genuine 404, not because the id is fake but because it isn't a Book
- Case sensitivity — 'python' and 'Python' are stored as two distinct tags; no normalization is applied
- Filtering — GET /api/items?tag=X, scoped explicitly to itemType: 'Book' for clarity even though no other type could ever match
- Next chapter: Search — real lookup across the whole heterogeneous collection, not just tags on books