The Catalogue List & Detail Views in React

Personal Catalogue: React & Firebase

Chapter 7 · The Catalogue List & Detail Views in React

Every piece needed to actually see the catalogue already exists — Chapter 4's AddItemForm, Chapter 5's tag operations, Chapter 6's searchItems(). Nothing has rendered an actual item on screen yet. This chapter builds the two views that finally do — and finds that Firestore's own real-time capabilities quietly remove a whole category of manual wiring the MongoDB sibling's own equivalent chapter had to build by hand.

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 — the Same Rendering Bug, Backend-Agnostic

Every item type needs its own "creator" shown somewhere in the list. This particular bug has nothing to do with which database is behind it — it's a plain JavaScript coercion problem the MongoDB sibling's own Chapter 7 already found, and it would bite this course exactly as hard if it weren't caught here too:

// 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 Cd, Dvd, or Bluray, item.author is undefined — the template literal coerces it with String(undefined), producing the literal text "By undefined", which React then renders exactly as given. The fix is the same real one, unchanged from the sibling course:

// 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) returns a real null when no creator field exists, and null && <p>...</p> short-circuits without rendering anything at all — the correct outcome, not just a less-broken one.

The Item Detail Component: Reusing Chapter 4's FIELD_CONFIG

// src/components/ItemDetail.jsx import TagEditor from './TagEditor'; import { FIELD_CONFIG } from '../fieldConfig'; export default function ItemDetail({ item, onBack }) { 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 || []} /> )} {item.notes && <p>Notes: {item.notes}</p>} </div> ); }
No Extra Fetch Needed — for an Even More Structural Reason Than the MongoDB Sibling
The MongoDB sibling's own detail view skips a separate GET /api/items/:id call only because its list route uses .lean(), returning every field a document has rather than only the base Item schema's own fields. This course never needed an equivalent fix at all: Chapter 2 already established that a Firestore document fetched any way at all always carries every field it was written with — there was never a base-model-vs-subtype-model split here for a "lean" option to work around in the first place.

The Naive Data Flow: One Fetch, Then Drift

A first, reasonable approach loads the catalogue once on mount, the same shape every prior chapter's own standalone examples have used:

// A naive one-time fetch — works, but has the same real gap the MongoDB sibling found useEffect(() => { getDocs(collection(db, "items")).then((snapshot) => { setItems(snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))); }); }, []);

Open a Book's detail view, add a tag through TagEditor, click "Back to list," then reopen the same book: its detail view shows the old tags again. The write to Firestore genuinely succeeded — the document itself is correct — but the items array sitting in App's own React state was only ever populated once, at mount, and nothing told it a document underneath it had changed since. This is exactly the stale-data gap the MongoDB sibling's own Chapter 5 onChange prop exists to close.

The Real Fix: a Live Firestore Listener

Firestore's client SDK offers something the MongoDB sibling's own REST API has no equivalent of at all: a subscription that keeps delivering fresh data automatically, for as long as it stays open, with no manual "please refetch" call needed anywhere in the app.

import { collection, onSnapshot } from "firebase/firestore"; useEffect(() => { const unsubscribe = onSnapshot(collection(db, "items"), (snapshot) => { setItems(snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))); }); return () => unsubscribe(); // stop listening when this component unmounts }, []);

onSnapshot() fires immediately with the collection's own current state, exactly like the naive getDocs() version — but then fires again, automatically, every single time any document in the items collection changes for any reason: a new item created by AddItemForm, a tag added or removed by TagEditor, anything at all. setItems() is called with a genuinely fresh snapshot every time, with nothing in any other component needing to know this is happening.

Chapter 5's onChange Prop Is No Longer Load-Bearing
The MongoDB sibling's own Chapter 7 treats wiring TagEditor's onChange prop as the real fix for the stale-list problem — a genuine, necessary piece of manual state-lifting. Here, the exact same stale-list problem is already solved the moment onSnapshot() replaces the one-time fetch, with no prop wiring required at all: the tag write itself is what triggers a fresh snapshot, and the fresh snapshot is what refreshes items, and selectedItem (derived fresh from items on every render) reflects the change automatically. Chapter 5's own onChange prop still exists and still works, but it's no longer the thing actually keeping the list and detail view in sync — that job now belongs to the listener, not to any one component remembering to call a callback.

The same mechanism quietly removes a second piece of manual wiring, too: AddItemForm's own onCreated callback, previously needed to append a freshly created item into the shared array by hand, is no longer required for the list to show a new item — the listener already picks it up the moment addDoc() succeeds.

Not Free — Just a Different, Usually Smaller Cost
A live listener still counts against Firestore's own per-document-read billing from Chapter 6: the initial snapshot reads every document once, and each later update reads only the documents that actually changed — typically far cheaper than Chapter 6's own repeated full-collection re-fetch on every search, but not literally free. Forgetting to call the returned unsubscribe() function when a component unmounts leaves the listener running in the background indefinitely, a real, avoidable source of both wasted reads and a memory leak.

Wiring State in App.jsx

// src/App.jsx import { useState, useEffect } from 'react'; import { collection, onSnapshot } from "firebase/firestore"; import { db } from './firebase'; 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 [filtered, setFiltered] = useState(null); // null means "no active filter, show everything" const [selectedId, setSelectedId] = useState(null); useEffect(() => { const unsubscribe = onSnapshot(collection(db, "items"), (snapshot) => { setItems(snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))); }); return () => unsubscribe(); }, []); const selectedItem = items.find((it) => it.id === selectedId); const displayed = filtered ?? items; return ( <div> <h1>Personal Catalogue</h1> {!selectedItem && ( <> <SearchBar onResults={setFiltered} /> <TagFilter onResults={setFiltered} /> <AddItemForm onCreated={() => setFiltered(null)} /> </> )} {selectedItem ? ( <ItemDetail item={selectedItem} onBack={() => setSelectedId(null)} /> ) : ( <ItemList items={displayed} onSelect={setSelectedId} /> )} </div> ); }

items is now the live, listener-driven source of truth; filtered holds whatever SearchBar or TagFilter most recently returned, and clearing it after a new item is created is enough to bring the full, current list back into view — since items itself is already correct the instant the listener's own next snapshot arrives.

TagFilter is a thin wrapper around Chapter 5's own findBooksByTag() function, exactly the way SearchBar already wraps Chapter 6's searchItems():

// src/components/TagFilter.jsx import { useState } from 'react'; import { findBooksByTag } from '../tagOperations'; export default function TagFilter({ onResults }) { const [tag, setTag] = useState(''); async function search() { onResults(tag ? await findBooksByTag(tag) : null); } return ( <div> <input value={tag} onChange={(e) => setTag(e.target.value)} placeholder="Filter by tag" /> <button onClick={search}>Search</button> </div> ); }
Two Separate Filter Controls, Deliberately
SearchBar and TagFilter stay two distinct controls, for the same reason the MongoDB sibling keeps them separate — they run genuinely different real operations. SearchBar's own Chapter 6 logic fetches everything and filters client-side; TagFilter's own Chapter 5 logic sends a real, targeted array-contains query to Firestore. Merging them into one input would hide that real difference rather than resolve it.

Trying It in the Browser

Add one item of each type through the form and watch the list update itself with no reload — the listener already has it. Open a Book, add a tag through the real TagEditor, click "Back to list," then reopen the same book: the new tag is already there, with no onChange prop, no manual state update, and no page refresh involved anywhere in making that happen.

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 replace the onSnapshot listener in App.jsx with the earlier one-time getDocs() fetch, reproduce the stale-tags bug (edit a book's tags, go back, reopen it), and confirm the old tags are shown. Restore the listener and confirm the fix — with no other code changed anywhere.

📄 View solution
Exercise 3

Explain precisely why switching to onSnapshot() fixes the stale-tags bug without needing any change to TagEditor, ItemDetail, or AddItemForm — and name the one real, ongoing cost that switch introduces which a one-time getDocs() call never had.

📄 View solution

Chapter 7 Quick Reference

  • getCreator(item) — the same backend-agnostic "By undefined" rendering bug the MongoDB sibling found, fixed the identical way
  • FIELD_CONFIG reused — the same object from Chapter 4 drives both the add-item form and the detail view
  • No extra fetch on detail — true here for an even more fundamental reason than the MongoDB sibling's .lean() fix: every Firestore document always carries every field it was written with
  • onSnapshot() — a live listener replacing a one-time fetch; fires on mount and again on every future change, anywhere
  • Manual sync wiring eliminated — Chapter 5's onChange prop and AddItemForm's onCreated callback are both no longer required to keep the list and detail view in sync
  • Not free — a live listener still counts real Firestore reads and must be unsubscribed on unmount
  • Next chapter: Fast Manual Entry — reducing friction in the add-item flow given the project's own real deadline