The Add-Item Form
Personal Catalogue: React, Express & MongoDB
Chapter 4 · The Add-Item Form
Chapter 3's own POST /api/items route already knows how to create any of the four item types —
it just needs a real request body naming one. This chapter builds the one React form that actually produces
that body: a single component that changes shape depending on which item type is selected, rather than four
separate forms wired to four separate endpoints.
A Field Configuration Object, Not Four Separate Forms
Every item type needs different inputs, but "different inputs" doesn't have to mean "different components." A single declarative object, matched field-for-field against Chapter 2's own four discriminator schemas, is enough to drive one shared rendering loop:
| Type | Backend schema field (Ch.2) | Frontend config field |
|---|---|---|
| Book | author (required) | author, marked required |
isbn | isbn | |
publisher | publisher | |
tags: [String] | tags, comma-separated text → array | |
| Cd | artist (required) | artist, marked required |
tracklist: [String] | tracklist, comma-separated text → array | |
| Dvd | director | director |
runtimeMinutes: Number | runtimeMinutes, numeric input | |
regionCode | regionCode | |
| Bluray | director | director |
runtimeMinutes: Number | runtimeMinutes, numeric input | |
resolution: enum('1080p','4K') | resolution, a select with those two options |
FIELD_CONFIG[itemType] and
picking a text input, a number input, or a select based on that one field's own options/type
properties. Adding a fifth item type later would mean adding one more entry to this object and one more
discriminator on the backend — not a fifth JSX form.
The Type Selector & Shared Fields
title, releaseYear, and notes live on Chapter 2's own base
Item schema, so every item type shares them regardless of which type is currently selected.
They get their own piece of state, separate from whatever type-specific fields are showing:
Rendering Type-Specific Fields From the Config
The dynamic portion of the form is a single .map() over whichever config array matches the
currently selected type — a select for a field with options, otherwise a text or number input:
A Real Client-State Bug: Leftover Fields From a Previous Type
typeFields is one shared object holding whichever fields the currently selected type happens to
use. Nothing shown so far ever clears it when itemType itself changes — and that gap produces a
real, reproducible problem. Select Book, type an author, then switch the dropdown to Cd without touching
anything else:
- Book is selected.
typeFieldsbecomes{ author: 'Ursula K. Le Guin' }. - The dropdown changes to Cd.
typeFieldsis untouched — it's still{ author: 'Ursula K. Le Guin' }, even though no author field is rendered anymore. - An artist value is typed.
typeFieldsis now{ author: 'Ursula K. Le Guin', artist: 'Kraftwerk' }. - The form submits with
itemType: 'Cd'and both fields in the outgoing body.
Chapter 3's own POST /api/items route resolves MODEL_MAP.Cd and calls
Cd.create(req.body) against that full body — the one with the leftover author field
still attached.
author body against the real /api/items route and then
inspecting the saved document (via GET /api/items/<id> or directly in mongosh)
shows a clean Cd document — itemType, title, artist — with
no author field anywhere on it. Chapter 2's discriminatorKey options
object never set strict: false, so Mongoose's own default (strict: true) applies on
writes exactly the way it already shaped Chapter 2's reads: any field passed to .create() that
isn't part of the target schema is silently dropped before the document is ever saved — no error, no partial
write, just quietly ignored.
So the data itself was never really at risk. The real problem is on screen, not in the database: switching back to Book a moment later would show that stale author text sitting in the input again, as if it had never left — a confusing, unreliable form, even though every actual save has stayed correct the whole time.
The Fix: Resetting Type-Specific State on Type Change
The fix is one line, in the handler that already runs every time the dropdown changes:
Converting Comma-Separated Input Into Real Arrays
Chapter 2's own tags: [String] and tracklist: [String] fields expect real arrays,
but a plain text input can only ever produce one string. A comma-separated field is split, trimmed, and
filtered right before the request goes out, not stored as an array in state the whole time — keeping every
text input, including these two, genuinely just a plain string while it's being typed:
Typing Python, Web Development, Programming into the Tags field becomes
['Python', 'Web Development', 'Programming'] in the payload — exactly the shape
Book's own schema expects, with no trailing empty entries even if a stray comma or extra space
slips into what was typed.
Submitting the Form
releaseYear gets the same real-vs-string treatment runtimeMinutes needs — both are
typed as strings by the DOM regardless of input type, but Mongoose's own Number SchemaType casts a valid
numeric string automatically on write, confirmed directly by checking a saved document's own
typeof releaseYear in mongosh and finding a real number, not a string.
Converting explicitly here is still worth doing, so an empty field submits as genuinely absent rather than as
an empty string Mongoose would otherwise reject:
A failed request surfaces exactly the message Chapter 3's own error handling produces — a real Mongoose
ValidationError message for a missing required field like author, or the "Unknown
itemType" message for anything not in MODEL_MAP.
Trying It in the Browser
With both the Express API (Chapter 1, port 4000) and the Vite dev server running, mounting
<AddItemForm onCreated={(item) => console.log('Created:', item)} /> in App.jsx
is enough to test all four types end to end: select each type in turn, fill in its own fields, and confirm the
logged item in the browser console — then cross-check with curl http://localhost:4000/api/items
to confirm the same four documents exist server-side, with genuinely different fields on each one. Chapter 7
replaces that console log with the real catalogue list this form is actually populating.
Hands-On Exercises
Build the full AddItemForm component with FIELD_CONFIG covering all four types, wire it into App.jsx, and create one real item of each type through the actual UI, confirming each one via GET /api/items.
📄 View solutionTemporarily remove the setTypeFields({}) reset from handleTypeChange, reproduce the leftover-field bug (fill a Book's author, switch to Cd, fill artist, submit), and confirm via a direct GET /api/items/:id that the saved Cd document has no author field despite it being present in the outgoing request body. Explain why.
📄 View solutionThe required flags on the Book and Cd form fields (author, artist) only produce a browser-level warning if left empty. Explain why this doesn't replace the backend's own required: true validation from Chapter 2, and describe a real request that would bypass the frontend's check entirely.
📄 View solutionChapter 4 Quick Reference
- FIELD_CONFIG — one declarative object, matched field-for-field against Chapter 2's own discriminator schemas, driving a single rendering loop instead of four separate forms
- Shared vs. type-specific state — title/releaseYear/notes live in one piece of state; author/artist/director/etc. live in another, reset whenever itemType changes
- The leftover-field bug — switching types without resetting typeFields lets a stale field ride along in the request body; Mongoose's default strict mode silently drops it server-side regardless
- Arrays from text — comma-separated input is split/trimmed/filtered into a real array immediately before the request, never stored as an array in state
- Numbers from text — releaseYear is explicitly converted with Number(); runtimeMinutes would actually be cast correctly by Mongoose even as a string, verified directly against a saved document's own typeof
- Next chapter: Tags — storing, attaching, and filtering by tag on books specifically