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.

// A naive, read-modify-write approach — works, but has a real problem below async function addTagNaively(bookId, newTag) { const book = await (await fetch(`http://localhost:4000/api/items/${bookId}`)).json(); const updatedTags = [...book.tags, newTag]; return fetch(`http://localhost:4000/api/items/${bookId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: updatedTags }), }); }

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

TimeTab ATab BTags actually saved in MongoDB
t0Loads the book, sees ['Python', 'Programming']Loads the book, sees ['Python', 'Programming']['Python', 'Programming']
t1Adds '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:

// routes/items.js (continued) // PATCH /api/items/:id/tags/add — atomically add one tag to a Book, no duplicates router.patch('/:id/tags/add', async (req, res) => { const { tag } = req.body; if (!tag) { return res.status(400).json({ error: 'tag is required' }); } const updated = await Book.findByIdAndUpdate( req.params.id, { $addToSet: { tags: tag } }, { new: true, runValidators: true } ).lean(); if (!updated) { return res.status(404).json({ error: 'Item not found' }); } res.json(updated); });
// routes/items.js (continued) // PATCH /api/items/:id/tags/remove — atomically remove one tag from a Book router.patch('/:id/tags/remove', async (req, res) => { const { tag } = req.body; if (!tag) { return res.status(400).json({ error: 'tag is required' }); } const updated = await Book.findByIdAndUpdate( req.params.id, { $pull: { tags: tag } }, { new: true } ).lean(); if (!updated) { return res.status(404).json({ error: 'Item not found' }); } res.json(updated); });

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 vs. $push
$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.
Confirmed Directly: Discriminators Filter Automatically, Even Here
Calling 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.
Tag Matching Is Case-Sensitive
$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:

// src/components/TagEditor.jsx import { useState } from 'react'; export default function TagEditor({ bookId, initialTags, onChange }) { const [tags, setTags] = useState(initialTags); const [newTag, setNewTag] = useState(''); async function addTag() { if (!newTag.trim()) return; const res = await fetch(`http://localhost:4000/api/items/${bookId}/tags/add`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag: newTag.trim() }), }); const updated = await res.json(); setTags(updated.tags); setNewTag(''); onChange?.(updated.tags); } async function removeTag(tag) { const res = await fetch(`http://localhost:4000/api/items/${bookId}/tags/remove`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag }), }); const updated = await res.json(); setTags(updated.tags); onChange?.(updated.tags); } return ( <div> {tags.map((tag) => ( <span key={tag} className="tag-pill"> {tag} <button onClick={() => removeTag(tag)}>&times;</button> </span> ))} <input value={newTag} onChange={(e) => setNewTag(e.target.value)} placeholder="Add a tag" /> <button onClick={addTag}>Add</button> </div> ); }

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.

// routes/items.js (updated from Chapter 3) // GET /api/items — list every item, optionally filtered to Books carrying a specific tag router.get('/', async (req, res) => { const { tag } = req.query; const filter = tag ? { itemType: 'Book', tags: tag } : {}; const items = await Item.find(filter).lean(); res.json(items); });

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.

// src/components/TagFilter.jsx import { useState } from 'react'; export default function TagFilter({ onResults }) { const [tag, setTag] = useState(''); async function search() { const url = tag ? `http://localhost:4000/api/items?tag=${encodeURIComponent(tag)}` : 'http://localhost:4000/api/items'; const items = await (await fetch(url)).json(); onResults(items); } return ( <div> <input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="Filter by tag" /> <button onClick={search}>Search</button> </div> ); }

Trying It End to End

# Add a tag curl -X PATCH http://localhost:4000/api/items/<book-id>/tags/add \ -H "Content-Type: application/json" \ -d '{"tag":"Web Development"}' # Adding the same tag again is a genuine no-op curl -X PATCH http://localhost:4000/api/items/<book-id>/tags/add \ -H "Content-Type: application/json" \ -d '{"tag":"Web Development"}' # Remove a tag curl -X PATCH http://localhost:4000/api/items/<book-id>/tags/remove \ -H "Content-Type: application/json" \ -d '{"tag":"Programming"}' # Filter the whole catalogue by tag curl "http://localhost:4000/api/items?tag=Python" # Confirmed: the discriminator's own automatic filter, even for a genuinely existing id curl -X PATCH http://localhost:4000/api/items/<a-real-cd-id>/tags/add \ -H "Content-Type: application/json" \ -d '{"tag":"Anything"}' # → 404 Item not found

Hands-On Exercises

Exercise 1

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

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

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

Chapter 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