The Catalogue List & Detail Views

Personal Catalogue: React, Express & MongoDB

Chapter 7 · The Catalogue List & Detail Views

Every piece needed to actually see the catalogue already exists — Chapter 4's AddItemForm, Chapter 5's TagEditor and TagFilter, Chapter 6's SearchBar, and Chapter 3's list/detail routes underneath all of them. Nothing has rendered an actual item on screen yet. This chapter builds the two views that finally do: a compact list across every type, and a detail view that shows a specific item's own full, real shape.

Two Views, Two Real Responsibilities

AspectList ViewDetail View
Data shownTitle, type, creator, release year — a compact summaryEvery field the item's own type actually has, via Chapter 4's FIELD_CONFIG
Tag editingNot availableThe full TagEditor from Chapter 5, Book only
NavigationClick an item to open its detail view"Back to list" returns
Data sourceThe shared items array in App stateThe same shared array — no extra fetch

A Naive Summary Card — And a Real Rendering Bug

Every item type needs its own "creator" shown somewhere in the list — a Book's author, a Cd's artist, a Dvd's or Bluray's director. A first, reasonable-looking attempt reaches for a template literal:

// A naive summary line — looks fine for a Book, breaks for everything else <li key={item._id}> <strong>{item.title}</strong> <p>{`By ${item.author}`}</p> <span>({item.releaseYear})</span> </li>

For a Book, this looks completely correct — item.author holds a real name, and the template literal produces exactly the string expected. For a Cd, a Dvd, or a Bluray, item.author doesn't exist at all; it's undefined. JavaScript's own template-literal interpolation coerces that with String(undefined), which returns the literal four-character string "undefined" — and because that's now a genuine string value, React renders it exactly as given. Every non-Book item in the list ends up visibly showing "By undefined" on screen.

This Is Different From a Plain JSX Child
Writing <p>By {'{item.author}'}</p> instead — with the value interpolated directly as a separate JSX child, not inside a template literal string — wouldn't produce the literal word "undefined." React treats undefined as an empty, invisible child when it appears directly in JSX. But it isn't really fixed either: the visible result for a non-Book item becomes a stray, orphaned "By" label with no name after it — a smaller bug, but still a real one.

The Real Fix: A getCreator() Helper and Conditional Rendering

The actual fix needs to know whether a creator exists at all, not just avoid coercing a missing one into text:

// src/components/ItemList.jsx function getCreator(item) { return item.author || item.artist || item.director || null; } export default function ItemList({ items, onSelect }) { if (items.length === 0) { return <p>No items yet — add one above.</p>; } return ( <ul> {items.map((item) => ( <li key={item._id} onClick={() => onSelect(item._id)}> <strong>{item.title}</strong>{' '} <span className="type-badge">{item.itemType}</span> {getCreator(item) && <p>By {getCreator(item)}</p>} {item.releaseYear && <span>({item.releaseYear})</span>} </li> ))} </ul> ); }

getCreator(item) genuinely returns null when none of the three creator fields exists — not the string "null", an actual null value. null && <p>...</p> short-circuits to null without ever evaluating the JSX on the right, and React renders nothing for it — the "By" line simply doesn't appear at all for a Cd, Dvd, or Bluray, which is the actually correct behavior, not just a less-broken one.

The Item Detail Component: Reusing Chapter 4's FIELD_CONFIG

The detail view needs to know exactly which fields a given item's own type has — the same information Chapter 4's FIELD_CONFIG already encodes for the add-item form. Importing it here instead of writing a second, separate list means the two can never quietly drift apart:

// src/components/ItemDetail.jsx import TagEditor from './TagEditor'; import { FIELD_CONFIG } from '../fieldConfig'; export default function ItemDetail({ item, onBack, onTagsChange }) { const fields = FIELD_CONFIG[item.itemType] || []; return ( <div> <button onClick={onBack}>&larr; Back to list</button> <h2>{item.title}</h2> <p className="type-badge">{item.itemType}</p> {item.releaseYear && <p>Released: {item.releaseYear}</p>} {fields .filter((field) => field.name !== 'tags') .map((field) => { const value = item[field.name]; if (!value || (Array.isArray(value) && value.length === 0)) return null; return ( <p key={field.name}> {field.label}: {Array.isArray(value) ? value.join(', ') : value} </p> ); })} {item.itemType === 'Book' && ( <TagEditor bookId={item._id} initialTags={item.tags || []} onChange={onTagsChange} /> )} {item.notes && <p>Notes: {item.notes}</p>} </div> ); }

tags is deliberately excluded from the generic field loop and rendered by the real TagEditor instead — every other array field (Cd's own tracklist, say) still falls through the same loop and is joined into a plain comma-separated line, since only tags need to actually be editable here.

Chapter 5's onChange Prop, Finally Used
TagEditor's onChange prop has existed since Chapter 5, calling onChange?.(updated.tags) after every real add or remove — but nothing ever passed a real function into it until now. Wiring it to ItemDetail's own onTagsChange prop closes that loop: editing a book's tags here now propagates back up to the shared list, resolving the exact stale-data problem this chapter would otherwise have — going back to the list after a tag edit and seeing the book still showing its old tags, because two separate copies of the same document had drifted apart.
No Extra Fetch Needed
The detail view never calls GET /api/items/:id at all — it just looks up the matching item already sitting in the parent's own items array. That's only safe because Chapter 3's list route uses .lean(), which returns every field a document actually has, not just the ones on Item's own base schema. Without that fix, the list's own copy of a Book would be missing author and tags entirely, and this shortcut wouldn't work.

Wiring State in App.jsx

One shared items array, lifted into App, is what lets every other component — the form, the tag filter, the search bar, the detail view — affect the exact same list rather than each holding its own disconnected copy:

// src/App.jsx import { useState, useEffect } from 'react'; import AddItemForm from './components/AddItemForm'; import SearchBar from './components/SearchBar'; import TagFilter from './components/TagFilter'; import ItemList from './components/ItemList'; import ItemDetail from './components/ItemDetail'; export default function App() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); useEffect(() => { fetch('http://localhost:4000/api/items') .then((res) => res.json()) .then((data) => { setItems(data); setLoading(false); }); }, []); function handleCreated(newItem) { setItems((prev) => [...prev, newItem]); } function handleTagsChange(itemId, newTags) { setItems((prev) => prev.map((it) => (it._id === itemId ? { ...it, tags: newTags } : it)) ); } const selectedItem = items.find((it) => it._id === selectedId); return ( <div> <h1>Personal Catalogue</h1> {!selectedItem && ( <> <SearchBar onResults={setItems} /> <TagFilter onResults={setItems} /> <AddItemForm onCreated={handleCreated} /> </> )} {loading ? ( <p>Loading catalogue…</p> ) : selectedItem ? ( <ItemDetail item={selectedItem} onBack={() => setSelectedId(null)} onTagsChange={(tags) => handleTagsChange(selectedItem._id, tags)} /> ) : ( <ItemList items={items} onSelect={setSelectedId} /> )} </div> ); }

Search and tag filtering both call setItems directly, replacing the whole displayed list with whatever the server just returned — exactly matching how Chapter 5's TagFilter and Chapter 6's SearchBar were already built, with no changes needed to either. Creating a new item appends to the existing array instead of replacing it, since a fresh item shouldn't make an active search or tag filter disappear.

Two Separate Filter Controls, Deliberately
SearchBar and TagFilter stay two distinct controls rather than being merged into one. They call different routes for different reasons — SearchBar's own regex match against title/author/artist/director from Chapter 6, TagFilter's own exact tag match from Chapter 5 — and combining them into a single input would either lose that distinction or need real logic to decide which endpoint a given term should hit. Two simple controls are more honest here than one clever one.

Trying It in the Browser

With both servers running, add one item of each type through the form and watch the list itself: every entry shows its own correct creator with no stray "By undefined" text anywhere, and clicking any item opens its own detail view with exactly the fields that type actually has. Open a Book, add a tag through the real TagEditor, then click "Back to list" — the book's own list entry doesn't show tags directly, but reopening its detail view immediately reflects the change, confirming the shared items array genuinely updated rather than the edit only existing inside the now-closed detail view.

Hands-On Exercises

Exercise 1

Build ItemList, ItemDetail, and the updated App.jsx exactly as this chapter describes, then create one item of each type and confirm every list entry shows a correct creator line (or none at all for a type with no creator field set) with no "By undefined" text anywhere.

📄 View solution
Exercise 2

Temporarily revert ItemList's summary line to the naive template-literal version (`By ${item.author}`) and confirm the real "By undefined" text appears for a Cd, Dvd, or Bluray. Restore the getCreator() version and confirm the fix.

📄 View solution
Exercise 3

Temporarily remove the onTagsChange wiring between ItemDetail and App (pass no onChange to TagEditor at all), then edit a book's tags, go back to the list, and reopen it — confirm the stale-tags bug reappears. Restore the wiring and confirm the fix.

📄 View solution

Chapter 7 Quick Reference

  • getCreator(item) — returns whichever of author/artist/director exists, or a real null, driving genuinely conditional rendering rather than a coerced "undefined" string
  • Template literals vs. JSX children — string interpolation coerces a missing value into the literal text "undefined"; a plain JSX child silently renders nothing instead, but still needs a real presence check for a correct result
  • FIELD_CONFIG reused — the exact same object from Chapter 4 drives both which inputs the form shows and which fields the detail view displays
  • No extra fetch on detail — a direct, explained payoff of Chapter 3's own .lean() fix; every field is already present in the shared list
  • TagEditor's onChange, finally wired — closes the loop Chapter 5 left open, keeping the list and detail view's own copies of tags in sync
  • Two filter controls, not one — SearchBar and TagFilter stay separate since they genuinely query different things
  • Next chapter: Fast Manual Entry — reducing friction in the add-item flow given the project's own real deadline